mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
140 lines
4.8 KiB
TypeScript
140 lines
4.8 KiB
TypeScript
import os from 'node:os'
|
|
import { app, ipcMain, net } from 'electron'
|
|
|
|
// Why: the production Mac build loads the renderer from a file:// origin, so a
|
|
// cross-origin POST from fetch() triggers a CORS preflight that the feedback
|
|
// endpoint rejects. Electron's net module runs in the main process and is not
|
|
// subject to CORS, so we proxy the submission through IPC. This mirrors the
|
|
// same pattern used by updater-changelog.ts and updater-nudge.ts.
|
|
const FEEDBACK_API_URL = 'https://api.onorca.dev/v1/feedback'
|
|
const FEEDBACK_API_FALLBACK_URL = 'https://www.onorca.dev/v1/feedback'
|
|
const FEEDBACK_REQUEST_TIMEOUT_MS = 10_000
|
|
|
|
export type FeedbackSubmissionType = 'feedback' | 'crash'
|
|
|
|
export type FeedbackSubmitArgs = {
|
|
feedback: string
|
|
submitAnonymously?: boolean
|
|
githubLogin: string | null
|
|
githubEmail: string | null
|
|
}
|
|
|
|
type FeedbackSubmitBody = {
|
|
feedback: string
|
|
submissionType: FeedbackSubmissionType
|
|
githubLogin: string | null
|
|
githubEmail: string | null
|
|
appVersion: string
|
|
platform: NodeJS.Platform
|
|
osRelease: string
|
|
arch: string
|
|
}
|
|
|
|
export type FeedbackSubmitResult =
|
|
| { ok: true }
|
|
| { ok: false; status: number | null; error: string }
|
|
|
|
type InternalFeedbackSubmitArgs = FeedbackSubmitArgs & {
|
|
submissionType?: FeedbackSubmissionType
|
|
}
|
|
|
|
// Why: the Slack notification and any follow-up investigation need to know
|
|
// which Orca build and which OS the feedback came from. The main process is
|
|
// the only place with trusted access to these values (app.getVersion and the
|
|
// node os module), so we enrich the payload here rather than trusting the
|
|
// renderer.
|
|
function buildSubmitBody(args: InternalFeedbackSubmitArgs): FeedbackSubmitBody {
|
|
const identity = args.submitAnonymously
|
|
? { githubLogin: null, githubEmail: null }
|
|
: { githubLogin: args.githubLogin, githubEmail: args.githubEmail }
|
|
|
|
// Why: anonymity is an IPC-only privacy decision. Allow-list fields here so
|
|
// stale renderer state or future identity-shaped fields cannot leak upstream.
|
|
return {
|
|
feedback: args.feedback,
|
|
submissionType: args.submissionType ?? 'feedback',
|
|
...identity,
|
|
appVersion: app.getVersion(),
|
|
platform: process.platform,
|
|
osRelease: os.release(),
|
|
arch: process.arch
|
|
}
|
|
}
|
|
|
|
async function postFeedback(url: string, body: FeedbackSubmitBody): Promise<Response> {
|
|
const controller = new AbortController()
|
|
// Why: a silent feedback endpoint should not leave IPC or crash-report
|
|
// submission flows pending forever.
|
|
const timeout = setTimeout(() => controller.abort(), FEEDBACK_REQUEST_TIMEOUT_MS)
|
|
try {
|
|
return await net.fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
signal: controller.signal
|
|
})
|
|
} finally {
|
|
clearTimeout(timeout)
|
|
}
|
|
}
|
|
|
|
function messageFromError(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error)
|
|
}
|
|
|
|
async function submitFallbackFeedback(
|
|
body: FeedbackSubmitBody,
|
|
primaryError?: unknown
|
|
): Promise<FeedbackSubmitResult> {
|
|
try {
|
|
const fallback = await postFeedback(FEEDBACK_API_FALLBACK_URL, body)
|
|
if (fallback.ok) {
|
|
return { ok: true }
|
|
}
|
|
return { ok: false, status: fallback.status, error: `status ${fallback.status}` }
|
|
} catch (fallbackError) {
|
|
const message = messageFromError(fallbackError)
|
|
if (primaryError === undefined) {
|
|
return { ok: false, status: null, error: message }
|
|
}
|
|
return {
|
|
ok: false,
|
|
status: null,
|
|
error: `${messageFromError(primaryError)}; fallback: ${message}`
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function submitFeedback(
|
|
args: InternalFeedbackSubmitArgs
|
|
): Promise<FeedbackSubmitResult> {
|
|
const body = buildSubmitBody(args)
|
|
try {
|
|
const res = await postFeedback(FEEDBACK_API_URL, body)
|
|
if (res.ok) {
|
|
return { ok: true }
|
|
}
|
|
// Why: DNS for api.onorca.dev can lag behind a deploy. Only fall back on
|
|
// 404/5xx-style results and network errors — don't mask real 4xx responses
|
|
// from a healthy host.
|
|
if (res.status === 404 || res.status >= 500) {
|
|
return submitFallbackFeedback(body)
|
|
}
|
|
return { ok: false, status: res.status, error: `status ${res.status}` }
|
|
} catch (error) {
|
|
// Why: falling back on any network-level failure preserves the prior
|
|
// behavior where DNS/connect failures on the primary host transparently
|
|
// try the website-hosted versioned endpoint.
|
|
return submitFallbackFeedback(body, error)
|
|
}
|
|
}
|
|
|
|
export function registerFeedbackHandlers(): void {
|
|
ipcMain.removeHandler('feedback:submit')
|
|
ipcMain.handle('feedback:submit', (_event, args: FeedbackSubmitArgs) =>
|
|
// Why: crash submissions are main-only. A compromised renderer can invoke
|
|
// this channel directly, so force the public feedback lane at the boundary.
|
|
submitFeedback({ ...args, submissionType: 'feedback' })
|
|
)
|
|
}
|