Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/actions/setup-build/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions .github/workflows/release-canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/release-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions apps/emdash-desktop/src/main/core/feedback/controller.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
},
});
43 changes: 43 additions & 0 deletions apps/emdash-desktop/src/main/core/feedback/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { env } from '@main/lib/env';
import { log } from '@main/lib/logger';

// Kept in sync with services/feedback-relay.
const RELAY_SECRET_HEADER = 'x-emdash-feedback-secret';

export interface FeedbackFileInput {
filename: string;
mimeType: string;
bytes: ArrayBuffer;
}

export interface SubmitFeedbackInput {
content: string;
files: FeedbackFileInput[];
}

export async function submitFeedback({ content, files }: SubmitFeedbackInput): Promise<void> {
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<string, string> = {};
const secret = env.build.VITE_FEEDBACK_RELAY_SECRET;
if (secret) {
headers[RELAY_SECRET_HEADER] = 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}`);
}
}
2 changes: 2 additions & 0 deletions apps/emdash-desktop/src/main/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/emdash-desktop/src/main/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -61,6 +62,7 @@ export const rpcRouter = createRPCRouter({
resourceMonitor: resourceMonitorController,
asana: asanaController,
featurebase: featurebaseController,
feedback: feedbackController,
forgejo: forgejoController,
files: machineFilesController,
github: githubController,
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
}
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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/1473390363388416230/eRIo1UhylapH94KpqUUp5PDzkLhjBvcnjjyE_JezfHiAyfN3QEbRyEIJaSl8QQUz7Mak';

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,
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions services/feedback-relay/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
.wrangler/
.dev.vars
Loading
Loading