diff --git a/.github/actions/setup-build/action.yml b/.github/actions/setup-build/action.yml index 851f05eb46..754b94aa10 100644 --- a/.github/actions/setup-build/action.yml +++ b/.github/actions/setup-build/action.yml @@ -14,6 +14,14 @@ inputs: description: Build variant to embed (canary | prod) required: false default: 'prod' + feedback-relay-url: + description: Feedback → Slack relay Worker URL + required: false + default: 'https://emdash-feedback-relay.real-general-action.workers.dev' + feedback-relay-secret: + description: Feedback relay shared secret + required: false + default: '' windows-native: description: Set to true on Windows to pass MSVC/gyp flags to pnpm install required: false @@ -64,7 +72,11 @@ runs: PH_KEY: ${{ inputs.posthog-key }} PH_HOST: ${{ inputs.posthog-host }} BUILD_VARIANT: ${{ inputs.build-variant }} + FEEDBACK_RELAY_URL: ${{ inputs.feedback-relay-url }} + FEEDBACK_RELAY_SECRET: ${{ inputs.feedback-relay-secret }} run: | echo "VITE_POSTHOG_KEY=$PH_KEY" >> apps/emdash-desktop/.env.production echo "VITE_POSTHOG_HOST=$PH_HOST" >> apps/emdash-desktop/.env.production echo "VITE_BUILD=$BUILD_VARIANT" >> apps/emdash-desktop/.env.production + echo "VITE_FEEDBACK_RELAY_URL=$FEEDBACK_RELAY_URL" >> apps/emdash-desktop/.env.production + echo "VITE_FEEDBACK_RELAY_SECRET=$FEEDBACK_RELAY_SECRET" >> apps/emdash-desktop/.env.production diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index 22eff72d99..ce3d78abb6 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -73,6 +73,7 @@ jobs: with: posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} posthog-host: ${{ secrets.POSTHOG_HOST }} + feedback-relay-secret: ${{ secrets.FEEDBACK_RELAY_SECRET }} build-variant: canary setup-python: 'false' @@ -115,6 +116,7 @@ jobs: with: posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} posthog-host: ${{ secrets.POSTHOG_HOST }} + feedback-relay-secret: ${{ secrets.FEEDBACK_RELAY_SECRET }} build-variant: canary windows-native: 'true' - name: Export Python path for native modules @@ -185,6 +187,7 @@ jobs: with: posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} posthog-host: ${{ secrets.POSTHOG_HOST }} + feedback-relay-secret: ${{ secrets.FEEDBACK_RELAY_SECRET }} build-variant: canary - name: Import Apple signing certificate diff --git a/.github/workflows/release-prod.yml b/.github/workflows/release-prod.yml index 78a40789a8..613f4d4dd4 100644 --- a/.github/workflows/release-prod.yml +++ b/.github/workflows/release-prod.yml @@ -75,6 +75,7 @@ jobs: with: posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} posthog-host: ${{ secrets.POSTHOG_HOST }} + feedback-relay-secret: ${{ secrets.FEEDBACK_RELAY_SECRET }} setup-python: 'false' - run: pnpm run build @@ -113,6 +114,7 @@ jobs: with: posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} posthog-host: ${{ secrets.POSTHOG_HOST }} + feedback-relay-secret: ${{ secrets.FEEDBACK_RELAY_SECRET }} windows-native: 'true' - name: Export Python path for native modules shell: bash @@ -179,6 +181,7 @@ jobs: with: posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} posthog-host: ${{ secrets.POSTHOG_HOST }} + feedback-relay-secret: ${{ secrets.FEEDBACK_RELAY_SECRET }} - name: Import Apple signing certificate uses: apple-actions/import-codesign-certs@v2 diff --git a/apps/emdash-desktop/src/main/core/feedback/controller.ts b/apps/emdash-desktop/src/main/core/feedback/controller.ts new file mode 100644 index 0000000000..252fb2b1ba --- /dev/null +++ b/apps/emdash-desktop/src/main/core/feedback/controller.ts @@ -0,0 +1,16 @@ +import { createRPCController } from '@shared/lib/ipc/rpc'; +import { submitFeedback, type SubmitFeedbackInput } from './service'; + +export const feedbackController = createRPCController({ + submit: async (input: SubmitFeedbackInput) => { + try { + await submitFeedback(input); + return { success: true as const }; + } catch (error) { + return { + success: false as const, + error: error instanceof Error ? error.message : String(error), + }; + } + }, +}); diff --git a/apps/emdash-desktop/src/main/core/feedback/service.ts b/apps/emdash-desktop/src/main/core/feedback/service.ts new file mode 100644 index 0000000000..c1a5c17379 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/feedback/service.ts @@ -0,0 +1,40 @@ +import { env } from '@main/lib/env'; +import { log } from '@main/lib/logger'; + +export interface FeedbackFileInput { + filename: string; + mimeType: string; + bytes: ArrayBuffer; +} + +export interface SubmitFeedbackInput { + content: string; + files: FeedbackFileInput[]; +} + +export async function submitFeedback({ content, files }: SubmitFeedbackInput): Promise { + const relayUrl = env.build.VITE_FEEDBACK_RELAY_URL; + if (!relayUrl) { + throw new Error('Feedback relay URL is not configured'); + } + + const formData = new FormData(); + formData.append('content', content); + files.forEach((file, index) => { + const blob = new Blob([file.bytes], { type: file.mimeType || 'application/octet-stream' }); + formData.append(`file${index}`, blob, file.filename); + }); + + const headers: Record = {}; + const secret = env.build.VITE_FEEDBACK_RELAY_SECRET; + if (secret) { + headers.authorization = `Bearer ${secret}`; + } + + const response = await fetch(relayUrl, { method: 'POST', body: formData, headers }); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + log.error('Feedback relay returned an error', { status: response.status, detail }); + throw new Error(`Feedback relay returned ${response.status}`); + } +} diff --git a/apps/emdash-desktop/src/main/lib/env.ts b/apps/emdash-desktop/src/main/lib/env.ts index f20ed78951..31eff8ba42 100644 --- a/apps/emdash-desktop/src/main/lib/env.ts +++ b/apps/emdash-desktop/src/main/lib/env.ts @@ -5,6 +5,8 @@ const buildSchema = z.object({ VITE_POSTHOG_KEY: z.string().optional(), VITE_POSTHOG_HOST: z.string().optional(), VITE_BUILD: z.enum(['canary', 'prod']).default('prod'), + VITE_FEEDBACK_RELAY_URL: z.string().optional(), + VITE_FEEDBACK_RELAY_SECRET: z.string().optional(), }); // Dev-only overrides: read from process.env (supports non-VITE_ prefixed vars, diff --git a/apps/emdash-desktop/src/main/rpc.ts b/apps/emdash-desktop/src/main/rpc.ts index a024cc196d..2bd39281c3 100644 --- a/apps/emdash-desktop/src/main/rpc.ts +++ b/apps/emdash-desktop/src/main/rpc.ts @@ -9,6 +9,7 @@ import { browserController } from './core/browser/controller'; import { conversationController } from './core/conversations/controller'; import { editorBufferController } from './core/editor/controller'; import { featurebaseController } from './core/featurebase/controller'; +import { feedbackController } from './core/feedback/controller'; import { machineFilesController } from './core/files/controller'; import { workspaceFileSystemController } from './core/files/file-system/controller'; import { fileTreeController } from './core/files/file-tree/controller'; @@ -61,6 +62,7 @@ export const rpcRouter = createRPCRouter({ resourceMonitor: resourceMonitorController, asana: asanaController, featurebase: featurebaseController, + feedback: feedbackController, forgejo: forgejoController, files: machineFilesController, github: githubController, diff --git a/apps/emdash-desktop/src/renderer/lib/components/feedback-modal/build-feedback-content.ts b/apps/emdash-desktop/src/renderer/lib/components/feedback-modal/build-feedback-content.ts new file mode 100644 index 0000000000..faf0eac2dc --- /dev/null +++ b/apps/emdash-desktop/src/renderer/lib/components/feedback-modal/build-feedback-content.ts @@ -0,0 +1,63 @@ +export interface FeedbackGithubUser { + login?: string; + name?: string; + html_url?: string; + email?: string; +} + +interface BuildFeedbackContentOptions { + feedback: string; + contactEmail: string; + githubUser?: FeedbackGithubUser | null; + appVersion?: string | null; + platformDisplayName?: string | null; + includeDiagnosticLogs?: boolean; +} + +// Standalone module (no IPC imports) so it can be unit-tested without a `window`. +export function buildFeedbackContent({ + feedback, + contactEmail, + githubUser, + appVersion, + platformDisplayName, + includeDiagnosticLogs, +}: BuildFeedbackContentOptions): string { + const trimmedFeedback = feedback.trim(); + const trimmedContact = contactEmail.trim(); + const metadataLines: string[] = []; + + if (trimmedContact) { + metadataLines.push(`Contact: ${trimmedContact}`); + } + + const githubLogin = githubUser?.login?.trim(); + const githubName = githubUser?.name?.trim(); + if (githubLogin || githubName) { + const parts: string[] = []; + if (githubName && githubLogin) { + parts.push(`${githubName} (@${githubLogin})`); + } else if (githubName) { + parts.push(githubName); + } else if (githubLogin) { + parts.push(`@${githubLogin}`); + } + metadataLines.push(`GitHub: ${parts.join(' ')}`); + } + + const trimmedAppVersion = appVersion?.trim(); + if (trimmedAppVersion) { + metadataLines.push(`Emdash Version: ${trimmedAppVersion}`); + } + + const trimmedPlatformDisplayName = platformDisplayName?.trim(); + if (trimmedPlatformDisplayName) { + metadataLines.push(`Platform: ${trimmedPlatformDisplayName}`); + } + + if (includeDiagnosticLogs) { + metadataLines.push('Diagnostic Logs: attached by user opt-in'); + } + + return [trimmedFeedback, metadataLines.join('\n')].filter(Boolean).join('\n\n'); +} diff --git a/apps/emdash-desktop/src/renderer/lib/components/feedback-modal/use-feedback-submit.test.ts b/apps/emdash-desktop/src/renderer/lib/components/feedback-modal/use-feedback-submit.test.ts index 6393488f03..ea7fcaa056 100644 --- a/apps/emdash-desktop/src/renderer/lib/components/feedback-modal/use-feedback-submit.test.ts +++ b/apps/emdash-desktop/src/renderer/lib/components/feedback-modal/use-feedback-submit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { buildFeedbackContent } from './build-feedback-content'; import { FEEDBACK_EMAIL_SCHEMA } from './schemas/feedback-email'; -import { buildFeedbackContent } from './use-feedback-submit'; describe('buildFeedbackContent', () => { it('includes feedback, metadata, and app version when provided', () => { diff --git a/apps/emdash-desktop/src/renderer/lib/components/feedback-modal/use-feedback-submit.ts b/apps/emdash-desktop/src/renderer/lib/components/feedback-modal/use-feedback-submit.ts index d16ee88f5e..a60c322838 100644 --- a/apps/emdash-desktop/src/renderer/lib/components/feedback-modal/use-feedback-submit.ts +++ b/apps/emdash-desktop/src/renderer/lib/components/feedback-modal/use-feedback-submit.ts @@ -1,84 +1,20 @@ import { useCallback, useState } from 'react'; import { useToast } from '@renderer/lib/hooks/use-toast'; +import { rpc } from '@renderer/lib/ipc'; import { log } from '@renderer/utils/logger'; +import { buildFeedbackContent, type FeedbackGithubUser } from './build-feedback-content'; import { FEEDBACK_EMAIL_SCHEMA } from './schemas/feedback-email'; -const DISCORD_WEBHOOK_URL = - 'https://discord.com/api/webhooks/1522337356185862194/pwsawgdkELfRDfT8y0dY463cfoAspkyRBVw6zjsS-oof2TJEqKm1igH6u1WFjL0PE0in'; - -const DISCORD_MAX_FILES = 10; -const DISCORD_MAX_PAYLOAD_BYTES = 8 * 1024 * 1024; - -interface GithubUser { - login?: string; - name?: string; - html_url?: string; - email?: string; -} +const FEEDBACK_MAX_FILES = 10; +const FEEDBACK_MAX_PAYLOAD_BYTES = 8 * 1024 * 1024; interface FeedbackSubmitOptions { - githubUser?: GithubUser | null; + githubUser?: FeedbackGithubUser | null; appVersion?: string | null; platformDisplayName?: string | null; onSuccess: () => void; } -interface BuildFeedbackContentOptions { - feedback: string; - contactEmail: string; - githubUser?: GithubUser | null; - appVersion?: string | null; - platformDisplayName?: string | null; - includeDiagnosticLogs?: boolean; -} - -export function buildFeedbackContent({ - feedback, - contactEmail, - githubUser, - appVersion, - platformDisplayName, - includeDiagnosticLogs, -}: BuildFeedbackContentOptions): string { - const trimmedFeedback = feedback.trim(); - const trimmedContact = contactEmail.trim(); - const metadataLines: string[] = []; - - if (trimmedContact) { - metadataLines.push(`Contact: ${trimmedContact}`); - } - - const githubLogin = githubUser?.login?.trim(); - const githubName = githubUser?.name?.trim(); - if (githubLogin || githubName) { - const parts: string[] = []; - if (githubName && githubLogin) { - parts.push(`${githubName} (@${githubLogin})`); - } else if (githubName) { - parts.push(githubName); - } else if (githubLogin) { - parts.push(`@${githubLogin}`); - } - metadataLines.push(`GitHub: ${parts.join(' ')}`); - } - - const trimmedAppVersion = appVersion?.trim(); - if (trimmedAppVersion) { - metadataLines.push(`Emdash Version: ${trimmedAppVersion}`); - } - - const trimmedPlatformDisplayName = platformDisplayName?.trim(); - if (trimmedPlatformDisplayName) { - metadataLines.push(`Platform: ${trimmedPlatformDisplayName}`); - } - - if (includeDiagnosticLogs) { - metadataLines.push('Diagnostic Logs: attached by user opt-in'); - } - - return [trimmedFeedback, metadataLines.join('\n')].filter(Boolean).join('\n\n'); -} - export function useFeedbackSubmit({ githubUser, appVersion, @@ -141,16 +77,16 @@ export function useFeedbackSubmit({ const files = diagnosticLog ? [...attachments, diagnosticLog] : attachments; - if (files.length > DISCORD_MAX_FILES) { + if (files.length > FEEDBACK_MAX_FILES) { setErrorMessage( - `Too many attachments (max ${DISCORD_MAX_FILES}). Remove some and try again.` + `Too many attachments (max ${FEEDBACK_MAX_FILES}). Remove some and try again.` ); setSubmitting(false); return; } const totalBytes = files.reduce((sum, file) => sum + file.size, 0); - if (totalBytes > DISCORD_MAX_PAYLOAD_BYTES) { + if (totalBytes > FEEDBACK_MAX_PAYLOAD_BYTES) { setErrorMessage('Attachments exceed the 8 MB total limit. Remove some and try again.'); setSubmitting(false); return; @@ -166,24 +102,17 @@ export function useFeedbackSubmit({ }); try { - let response: Response; - if (files.length > 0) { - const formData = new FormData(); - formData.append('content', content); - files.forEach((file, index) => { - formData.append(`file${index}`, file); - }); - response = await fetch(DISCORD_WEBHOOK_URL, { method: 'POST', body: formData }); - } else { - response = await fetch(DISCORD_WEBHOOK_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content }), - }); - } + const payloadFiles = await Promise.all( + files.map(async (file) => ({ + filename: file.name, + mimeType: file.type, + bytes: await file.arrayBuffer(), + })) + ); - if (!response.ok) { - throw new Error(`Discord webhook returned ${response.status}`); + const result = await rpc.feedback.submit({ content, files: payloadFiles }); + if (!result.success) { + throw new Error(result.error ?? 'Feedback submission failed'); } onSuccess();