diff --git a/FEATURES.md b/FEATURES.md index 5e1b5ced..6524c437 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -80,6 +80,7 @@ - [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、ZIP 或本地目录导入、切换和删除。 - [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。 - [x] **版本检查与镜像节点**:在“关于与更新”中选择 GitHub(默认)或镜像节点;手动检查、启动时检查和下载页使用同一选择,并只读取固定可信的发布索引,不自动下载或安装。 +- [x] **应用内反馈**:在“关于与更新”中直接提交问题、建议或体验反馈,支持可选邮箱和单张截图;失败后保留草稿并按同一请求编号重试,不自动附加对话、日志、文件、配置或凭据。 - [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。 ### 开源、构建与发布 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 14806d73..d97bdd15 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -640,6 +640,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。 - “平台功能”使用共享 `PageTabs` 区分“通用设置”和“魔法笔记”,默认进入通用设置。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。 - “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查和下载页,不显示底层服务商名称。 +- “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图,失败时保留草稿并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。 - Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。 - Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。 - “能力与默认配置”只显示一个模块标题,刷新入口位于该标题右侧,能力状态压缩为一行并排在默认 Agent 或 Continue 预设编辑器之前;不得再复制“Runtime 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。 diff --git a/docs/architecture/in-app-feedback-integration-design.md b/docs/architecture/in-app-feedback-integration-design.md index 02d5776a..4b12fe38 100644 --- a/docs/architecture/in-app-feedback-integration-design.md +++ b/docs/architecture/in-app-feedback-integration-design.md @@ -5,9 +5,9 @@ | 项目 | 内容 | | --- | --- | | 文档类型 | 跨功能产品与技术架构 | -| 状态 | 待实施 | -| 版本 | 0.1 | -| 日期 | 2026-08-24 | +| 状态 | 已实施并完成生产联调,待发布 | +| 版本 | 1.0 | +| 日期 | 2026-08-25 | | 适用产品 | GoodBuddy 桌面端 | | 目标平台 | Windows、macOS、Linux,x64 与 arm64 | | 相关基线 | [统一界面设计系统](../../UI-DESIGN.md)、[GoodBuddy 文档导航](../README.md) | @@ -49,8 +49,8 @@ Main 进程职责、公共 API 对接、失败处理和验收方法。反馈平 Runtime 配置、API Key、Token、Cookie、SSH 信息、剪贴板或屏幕内容。 12. 反馈提交失败时保留全部表单内容和截图,成功后显示可复制的服务端反馈编号。 13. 首版不提供反馈历史、状态查询、编辑、撤回、自动日志收集或服务端管理入口。 -14. 在真实反馈域名和可用服务尚未就绪时,不上线不可用按钮或占位入口。客户端和服务端应 - 在同一发布窗口完成可用性验证。 +14. 生产接口固定为 `https://imp.mesalogo.com/api/v1/feedback`。域名健康检查、 + TLS、v1 请求校验和客户端调用链必须在发布前一起通过。 --- @@ -127,7 +127,7 @@ Main 进程职责、公共 API 对接、失败处理和验收方法。反馈平 截图区域必须包含: - 缩略预览。 -- 尺寸和标准化后的大小。 +- 尺寸和所选文件大小;Main 仍在发送前独立执行 PNG 标准化和大小检查。 - `替换截图` 与 `移除截图`。 - `截图可能包含画面中可见的个人信息,请在发送前检查。` @@ -219,10 +219,10 @@ Main 进程职责、公共 API 对接、失败处理和验收方法。反馈平 反馈平台负责维护公共 API 的权威定义。GoodBuddy 只固定客户端实际需要的版本 1 子集: ```text -POST /api/v1/feedback +POST https://imp.mesalogo.com/api/v1/feedback ``` -GoodBuddy 实现前必须获得真实生产 Origin,并完成以下检查: +当前生产 Endpoint 已作为 Main-only 产品常量固定。实现和发布验证必须持续保证: - 使用 `https:`。 - URL 不含用户名、密码、查询参数或 fragment。 @@ -230,8 +230,8 @@ GoodBuddy 实现前必须获得真实生产 Origin,并完成以下检查: - 对应产品 `goodbuddy` 已在服务端启用。 - 桌面端所在网络能够直接访问该 Origin。 -生产 Endpoint 不写入文档示例值后直接发布。域名迁移必须同时修改客户端受信常量、测试、 -服务端 CORS/Origin 配置和部署文档。 +Renderer、Preload、用户设置、环境变量和远程配置都不能覆盖该 Endpoint。域名迁移必须 +同时修改客户端受信常量、测试、服务端 Origin 配置和部署文档。 ### 5.2 请求 JSON @@ -364,10 +364,13 @@ Main handler 必须: 1. 调用现有 `assertTrustedSender(event, window)`。 2. 使用 Shared Zod schema 解析 `unknown` 输入。 3. 不接收 URL、文件路径、Cookie、Header 或任意请求选项。 -4. 把 Promise 纳入现有 IPC 操作追踪,应用关闭时有界等待或取消。 +4. 由 `FeedbackService` 跟踪活动 Promise,应用关闭或清除数据时先取消再有界等待。 Preload 只转发已声明输入,不做网络请求或图片文件读取。 +反馈 handler 独立放在 `feedback/feedback-ipc.ts`,复用共享的 +`assertTrustedSender()`,避免继续扩展通用 `registerIpcHandlers()` 的位置参数列表。 + ### 6.4 FeedbackIdentityStore 新增 Main-only `FeedbackIdentityStore`: @@ -431,10 +434,8 @@ new Agent({ 每次请求必须满足: - 固定、预先校验的 HTTPS URL。 -- `redirect: "manual"`,任何 3xx 都失败。 -- `credentials: "omit"`。 -- `cache: "no-store"`。 -- `referrerPolicy: "no-referrer"`。 +- 使用 Undici 原始 `request()`,不安装重定向处理器;任何 3xx 都按服务不可用处理。 +- 不使用 Cookie Jar、缓存、Referrer 或浏览器凭据模式。 - `Accept: application/json`。 - 固定、非敏感 `User-Agent`,例如 `GoodBuddy-Feedback/`。 - 不发送 `Authorization`、Cookie、API Key 或自定义代理凭据。 @@ -446,7 +447,7 @@ new Agent({ - 日志只记录有界错误类别和 HTTP 状态,不记录请求正文、邮箱、截图、安装 UUID、 `clientRequestId` 或响应编号。 -测试可以注入 Endpoint、Dispatcher 和 Transport。生产构造路径不得读取测试覆盖值或 +测试可以注入 Endpoint、Dispatcher 和 Request Transport。生产构造路径不得读取测试覆盖值或 环境变量。 --- @@ -529,7 +530,9 @@ Main 不信任 Renderer 声明的 MIME: - 对话框同一时间只允许一个提交。 - Main 同一 `clientRequestId` 只运行一个请求。 -- 重复 IPC 调用合并或拒绝,不能并行上传两份截图。 +- Main 全局同一时间只发送一条反馈;相同 ID 的重复调用合并,不同 ID 在已有请求期间返回 + 有界 `busy` 结果,不能并行保留多份 5 MiB 截图。 +- 读取截图使用选择代次;旧文件后完成时必须丢弃并释放 Object URL,不能覆盖较新的截图。 ### 9.3 应用退出与清除数据 @@ -549,9 +552,9 @@ Main 不信任 Renderer 声明的 MIME: --- -## 10. 文件变更计划 +## 10. 实施文件 -实施时预计触及: +当前实现涉及: | 文件或目录 | 变更 | | --- | --- | @@ -562,8 +565,9 @@ Main 不信任 Renderer 声明的 MIME: | `src/main/feedback/feedback-screenshot.ts` | 签名、解码和 PNG 标准化 | | `src/main/feedback/feedback-http-client.ts` | 固定 Endpoint、严格 TLS、有界响应 | | `src/main/feedback/feedback-service.ts` | payload 组装与生命周期 | +| `src/main/feedback/feedback-ipc.ts` | 独立 trusted sender、schema parse 和 submit handler | +| `src/main/trusted-ipc-sender.ts` | Main IPC 共享 sender / main Frame 校验 | | `src/main/index.ts` | Main service 构造、清理和 IPC 注入 | -| `src/main/ipc.ts` | trusted sender、schema parse 和 submit handler | | `src/preload/index.ts` | 窄 `feedback.submit()` bridge | | `src/renderer/src/FeedbackDialog.tsx` | 表单和成功/失败流程 | | `src/renderer/src/UpdateSettingsSection.tsx` | About 入口卡片 | @@ -575,7 +579,10 @@ Main 不信任 Renderer 声明的 MIME: --- -## 11. 验证计划 +## 11. 验证 + +以下 Shared、Main、IPC、Preload 和 Renderer 行为均由相邻或现有自动化测试覆盖;生产 +Endpoint 的健康检查和无副作用 schema 探针也必须在发布前通过。 ### 11.1 Shared 合同 @@ -595,6 +602,7 @@ Main 不信任 Renderer 声明的 MIME: - 拒绝重定向、非 HTTPS、错误 Origin、超时、超大响应、非 JSON 和错误 response schema。 - 4xx、429、5xx 和网络错误映射。 - 手动重试复用 `clientRequestId`。 +- 不同 ID 的并发请求被有界拒绝,清除本地数据期间不能开始新提交。 ### 11.3 IPC 与 Preload @@ -609,6 +617,7 @@ Main 不信任 Renderer 声明的 MIME: - 表单字段、字符限制、中英文文案和隐私说明。 - 普通文本粘贴不被图片处理拦截。 - 截图选择、预览、替换、移除和 Object URL 清理。 +- 并发截图读取按最新选择生效,关闭对话框后不会留下待处理 Object URL。 - 失败保留字段,重试使用原 `clientRequestId`。 - 成功显示真实编号并清空后续新草稿。 - Dialog role、初始焦点、Tab 循环、Escape 和焦点恢复。 @@ -616,7 +625,16 @@ Main 不信任 Renderer 声明的 MIME: ### 11.5 真实集成 -在反馈平台部署到最终 HTTPS 域名后: +2026-08-25 已从隔离配置的真实 Electron 生产构建完成一次端到端合成提交: + +- 标题:`[GOODBUDDY INTEGRATION TEST] 2026-08-25` +- 类型:`other` +- 联系邮箱:未填写 +- 截图:GoodBuddy 公共应用图标,经 Main 重编码后以 PNG multipart 上传 +- 服务端编号:`GOODBUDDY-000001` + +该结果确认 About UI、Renderer、Preload、trusted IPC、安装身份、Main 图片处理、独立严格 +TLS Dispatcher 和生产 v1 API 调用链可以共同工作。发布候选仍应按以下清单复验: 1. 从打包候选的 About 页面提交一条明确标记的合成反馈。 2. 可选添加一张不含个人数据的测试截图。 @@ -646,22 +664,22 @@ npm run build API 版本。 - GoodBuddy 对响应执行严格解析。服务端需要扩展响应时,应先协调客户端兼容策略。 - Endpoint 域名变化属于客户端发布变更,不能依赖 301/302 迁移。 -- 若服务尚未部署或 `goodbuddy` 产品尚未启用,不把入口发布到用户。 +- 入口发布与 `goodbuddy` 产品启用状态、生产 TLS 和 v1 契约保持同一发布门槛。 - 此功能不迁移现有用户数据;只新增可重置的随机反馈安装身份。 - 发布说明应明确:用户现在可以在“关于与更新”内直接提交反馈,邮箱和截图可选,应用不会 自动附加对话、日志、文件或凭据。 --- -## 13. 实施前置条件 +## 13. 当前对接状态 -编码开始前必须确认: +截至 2026-08-25: -1. 生产反馈 HTTPS Origin。 -2. `/api/v1/feedback` 在该 Origin 可用。 -3. `goodbuddy` 产品已启用。 -4. 服务端公开 schema、截图上限和本文一致。 -5. 反馈平台的数据保留和隐私说明已可供产品文案引用。 -6. 真实集成测试应使用的环境和测试记录处理方式。 - -前置条件不满足时,本文保持“待实施”,不在产品中加入不可用入口。 +1. 生产 Origin 已确定为 `https://imp.mesalogo.com`,HTTPS、HSTS 和健康检查正常。 +2. `/api/v1/feedback` 已返回符合 v1 契约的严格校验错误,无副作用探针未创建记录。 +3. `goodbuddy` 产品已启用,实际提交返回 `GOODBUDDY-000001`。 +4. GoodBuddy 客户端已完成 Shared、Main、IPC、Preload、About UI、双语文案、截图和 + 生命周期实现。 +5. 服务端公开 schema、字段长度和 5 MiB 截图上限与客户端一致。 +6. 隔离 Electron 实例已完成浅色、深色、600×800 窄窗口、焦点与背景隔离、截图预览和 + 真实生产提交验收。 diff --git a/src/main/feedback/feedback-http-client.test.ts b/src/main/feedback/feedback-http-client.test.ts new file mode 100644 index 00000000..a86fd253 --- /dev/null +++ b/src/main/feedback/feedback-http-client.test.ts @@ -0,0 +1,247 @@ +import { FormData } from 'undici' +import { describe, expect, it, vi } from 'vitest' +import type { + FeedbackPublicPayload +} from '../../shared/feedback-contracts' +import { + createStrictFeedbackDispatcher, + GOODBUDDY_FEEDBACK_ENDPOINT, + StrictFeedbackHttpClient, + type FeedbackRequest +} from './feedback-http-client' + +const payload: FeedbackPublicPayload = { + schemaVersion: 1, + productKey: 'goodbuddy', + category: 'bug', + title: 'Feedback title', + description: 'A useful feedback description.', + environment: { + appVersion: '0.11.0', + platform: 'windows', + architecture: 'x64', + locale: 'zh-CN' + }, + installationId: '00000000-0000-4000-8000-000000000301', + clientRequestId: '00000000-0000-4000-8000-000000000302' +} + +function response( + statusCode: number, + value: string, + contentType = 'application/json; charset=utf-8' +) { + const destroy = vi.fn() + return { + response: { + statusCode, + headers: { + 'content-type': contentType, + 'content-length': String(Buffer.byteLength(value)) + }, + body: { + destroy, + async *[Symbol.asyncIterator]() { + yield Buffer.from(value) + } + } + }, + destroy + } +} + +function dispatcher() { + return { + close: vi.fn(async () => undefined) + } +} + +function client( + request: FeedbackRequest, + timeoutMilliseconds = 15_000 +) { + const strictDispatcher = dispatcher() + return { + client: new StrictFeedbackHttpClient({ + appVersion: '0.11.0', + dispatcher: strictDispatcher as never, + request, + timeoutMilliseconds + }), + dispatcher: strictDispatcher + } +} + +describe('StrictFeedbackHttpClient', () => { + it('creates a dedicated dispatcher with certificate validation enabled', () => { + const strictDispatcher = dispatcher() + const createAgent = vi.fn(() => strictDispatcher as never) + expect(createStrictFeedbackDispatcher(createAgent)).toBe( + strictDispatcher + ) + expect(createAgent).toHaveBeenCalledWith({ + connect: { + rejectUnauthorized: true + } + }) + }) + + it('posts JSON to the fixed endpoint without credentials', async () => { + const success = response( + 201, + JSON.stringify({ + reference: 'GOODBUDDY-000001', + duplicate: false + }) + ) + const request = vi.fn( + async () => success.response + ) + const fixture = client(request) + + await expect( + fixture.client.submit(payload, undefined, new AbortController().signal) + ).resolves.toEqual({ + reference: 'GOODBUDDY-000001', + duplicate: false + }) + expect(request).toHaveBeenCalledOnce() + const [url, options] = request.mock.calls[0]! + expect(url.toString()).toBe(GOODBUDDY_FEEDBACK_ENDPOINT) + expect(options.method).toBe('POST') + expect(options.headers).toEqual({ + accept: 'application/json', + 'content-type': 'application/json', + 'user-agent': 'GoodBuddy-Feedback/0.11.0' + }) + expect(JSON.parse(options.body as string)).toEqual(payload) + expect(JSON.stringify(options)).not.toMatch( + /authorization|cookie|api.?key/iu + ) + }) + + it('uses one payload field and one normalized PNG part', async () => { + const success = response( + 200, + JSON.stringify({ + reference: 'GOODBUDDY-000002', + duplicate: true + }) + ) + const request = vi.fn( + async () => success.response + ) + const fixture = client(request) + const screenshot = Buffer.from('normalized PNG') + + await fixture.client.submit( + payload, + screenshot, + new AbortController().signal + ) + const body = request.mock.calls[0]![1].body + expect(body).toBeInstanceOf(FormData) + const form = body as FormData + expect(JSON.parse(String(form.get('payload')))).toEqual(payload) + expect(form.getAll('payload')).toHaveLength(1) + expect(form.getAll('screenshot')).toHaveLength(1) + expect(form.get('screenshot')).toMatchObject({ + name: 'feedback.png', + type: 'image/png', + size: screenshot.byteLength + }) + }) + + it.each([ + [400, 'invalid-submission'], + [403, 'incompatible-client'], + [404, 'unavailable'], + [413, 'screenshot-too-large'], + [429, 'rate-limited'], + [503, 'service-error'], + [302, 'unavailable'] + ] as const)( + 'maps HTTP %i to %s without following it', + async (statusCode, code) => { + const failure = response(statusCode, '{"error":"failure"}') + const request = vi.fn( + async () => failure.response + ) + const fixture = client(request) + await expect( + fixture.client.submit( + payload, + undefined, + new AbortController().signal + ) + ).rejects.toMatchObject({ + code + }) + expect(failure.destroy).toHaveBeenCalledOnce() + } + ) + + it('rejects non-JSON and oversized success responses', async () => { + const wrongType = response(201, 'ok', 'text/plain') + const wrongTypeRequest = vi.fn( + async () => wrongType.response + ) + await expect( + client(wrongTypeRequest).client.submit( + payload, + undefined, + new AbortController().signal + ) + ).rejects.toMatchObject({ + code: 'invalid-response' + }) + + const largeBody = Buffer.alloc(16 * 1_024 + 1, 1) + const destroy = vi.fn() + const largeRequest = vi.fn(async () => ({ + statusCode: 201, + headers: { 'content-type': 'application/json' }, + body: { + destroy, + async *[Symbol.asyncIterator]() { + yield largeBody + } + } + })) + await expect( + client(largeRequest).client.submit( + payload, + undefined, + new AbortController().signal + ) + ).rejects.toMatchObject({ + code: 'invalid-response' + }) + expect(destroy).toHaveBeenCalledOnce() + }) + + it('bounds request time and closes only its own dispatcher', async () => { + const request = vi.fn( + async (_url, options) => + new Promise((_resolve, reject) => { + options.signal.addEventListener( + 'abort', + () => reject(options.signal.reason), + { once: true } + ) + }) + ) + const fixture = client(request, 5) + await expect( + fixture.client.submit( + payload, + undefined, + new AbortController().signal + ) + ).rejects.toMatchObject({ + code: 'timeout' + }) + await fixture.client.close() + expect(fixture.dispatcher.close).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/feedback/feedback-http-client.ts b/src/main/feedback/feedback-http-client.ts new file mode 100644 index 00000000..b062a1fc --- /dev/null +++ b/src/main/feedback/feedback-http-client.ts @@ -0,0 +1,294 @@ +import { + Agent, + FormData, + request as undiciRequest, + type Dispatcher +} from 'undici' +import { + feedbackPublicResponseSchema, + type FeedbackPublicPayload, + type FeedbackPublicResponse, + type FeedbackSubmissionErrorCode +} from '../../shared/feedback-contracts' + +export const GOODBUDDY_FEEDBACK_ENDPOINT = + 'https://imp.mesalogo.com/api/v1/feedback' + +const maximumResponseBytes = 16 * 1_024 +const defaultTimeoutMilliseconds = 15_000 + +type FeedbackResponseBody = AsyncIterable & { + destroy(error?: Error): void +} + +type FeedbackHttpResponse = { + statusCode: number + headers: Record + body: FeedbackResponseBody +} + +type FeedbackRequestOptions = { + dispatcher: Dispatcher + method: 'POST' + headers: Record + body: string | FormData + signal: AbortSignal + headersTimeout: number + bodyTimeout: number + idempotent: false + blocking: true +} + +export type FeedbackRequest = ( + url: URL, + options: FeedbackRequestOptions +) => Promise + +type StrictFeedbackAgentOptions = { + connect: { + rejectUnauthorized: true + } +} + +const defaultFeedbackRequest: FeedbackRequest = async ( + url, + options +) => { + const response = await undiciRequest(url, options) + return { + statusCode: response.statusCode, + headers: response.headers, + body: response.body + } +} + +export function createStrictFeedbackDispatcher( + createAgent: ( + options: StrictFeedbackAgentOptions + ) => Dispatcher = (options) => new Agent(options) +): Dispatcher { + return createAgent({ + connect: { + rejectUnauthorized: true + } + }) +} + +export class FeedbackClientError extends Error { + constructor(readonly code: FeedbackSubmissionErrorCode) { + super(code) + this.name = 'FeedbackClientError' + } +} + +function feedbackEndpoint(value: string): URL { + const endpoint = new URL(value) + if ( + endpoint.protocol !== 'https:' || + endpoint.username || + endpoint.password || + endpoint.search || + endpoint.hash || + endpoint.pathname !== '/api/v1/feedback' + ) { + throw new Error('Feedback endpoint must be the versioned HTTPS API') + } + return endpoint +} + +function headerValue( + headers: FeedbackHttpResponse['headers'], + name: string +): string | undefined { + const value = headers[name] ?? headers[name.toLowerCase()] + return Array.isArray(value) ? value[0] : value +} + +async function readBoundedBody( + body: FeedbackResponseBody +): Promise { + const chunks: Buffer[] = [] + let total = 0 + for await (const chunk of body) { + const bytes = Buffer.from( + chunk.buffer, + chunk.byteOffset, + chunk.byteLength + ) + total += bytes.byteLength + if (total > maximumResponseBytes) { + body.destroy() + throw new FeedbackClientError('invalid-response') + } + chunks.push(bytes) + } + return Buffer.concat(chunks, total) +} + +function mapStatus(statusCode: number): FeedbackSubmissionErrorCode { + if (statusCode === 400 || statusCode === 422) { + return 'invalid-submission' + } + if (statusCode === 403) { + return 'incompatible-client' + } + if (statusCode === 404 || (statusCode >= 300 && statusCode < 400)) { + return 'unavailable' + } + if (statusCode === 413) { + return 'screenshot-too-large' + } + if (statusCode === 429) { + return 'rate-limited' + } + if (statusCode >= 500) { + return 'service-error' + } + return 'unavailable' +} + +export class StrictFeedbackHttpClient { + private readonly endpoint: URL + private closed = false + + constructor( + private readonly options: { + appVersion: string + dispatcher: Dispatcher + request?: FeedbackRequest + endpoint?: string + timeoutMilliseconds?: number + } + ) { + this.endpoint = feedbackEndpoint( + options.endpoint ?? GOODBUDDY_FEEDBACK_ENDPOINT + ) + } + + async submit( + payload: FeedbackPublicPayload, + screenshot: Buffer | undefined, + externalSignal: AbortSignal + ): Promise { + if (this.closed) { + throw new FeedbackClientError('unavailable') + } + const controller = new AbortController() + const forwardAbort = (): void => { + controller.abort(externalSignal.reason) + } + if (externalSignal.aborted) { + forwardAbort() + } else { + externalSignal.addEventListener('abort', forwardAbort, { + once: true + }) + } + const timeoutMilliseconds = + this.options.timeoutMilliseconds ?? + defaultTimeoutMilliseconds + const timeout = setTimeout(() => { + controller.abort(new Error('Feedback request timed out')) + }, timeoutMilliseconds) + timeout.unref() + + try { + const serializedPayload = JSON.stringify(payload) + const headers: Record = { + accept: 'application/json', + 'user-agent': `GoodBuddy-Feedback/${this.options.appVersion}` + } + let body: string | FormData = serializedPayload + if (screenshot) { + const form = new FormData() + const screenshotBytes = new Uint8Array(screenshot.byteLength) + screenshotBytes.set(screenshot) + form.append('payload', serializedPayload) + form.append( + 'screenshot', + new File([screenshotBytes], 'feedback.png', { + type: 'image/png' + }) + ) + body = form + } else { + headers['content-type'] = 'application/json' + } + + const response = await ( + this.options.request ?? defaultFeedbackRequest + )(this.endpoint, { + dispatcher: this.options.dispatcher, + method: 'POST', + headers, + body, + signal: controller.signal, + headersTimeout: timeoutMilliseconds, + bodyTimeout: timeoutMilliseconds, + idempotent: false, + blocking: true + }) + if (response.statusCode !== 200 && response.statusCode !== 201) { + response.body.destroy() + throw new FeedbackClientError( + mapStatus(response.statusCode) + ) + } + const contentType = headerValue( + response.headers, + 'content-type' + ) + if ( + !contentType || + !/^application\/json(?:;|$)/iu.test(contentType) + ) { + response.body.destroy() + throw new FeedbackClientError('invalid-response') + } + const contentLength = Number( + headerValue(response.headers, 'content-length') + ) + if ( + Number.isFinite(contentLength) && + contentLength > maximumResponseBytes + ) { + response.body.destroy() + throw new FeedbackClientError('invalid-response') + } + const responseBody = await readBoundedBody(response.body) + let parsed: unknown + try { + parsed = JSON.parse(responseBody.toString('utf8')) as unknown + } catch { + throw new FeedbackClientError('invalid-response') + } + const result = feedbackPublicResponseSchema.safeParse(parsed) + if (!result.success) { + throw new FeedbackClientError('invalid-response') + } + return result.data + } catch (error) { + if (error instanceof FeedbackClientError) { + throw error + } + if (externalSignal.aborted) { + throw new FeedbackClientError('network') + } + if (controller.signal.aborted) { + throw new FeedbackClientError('timeout') + } + throw new FeedbackClientError('network') + } finally { + clearTimeout(timeout) + externalSignal.removeEventListener('abort', forwardAbort) + } + } + + async close(): Promise { + if (this.closed) { + return + } + this.closed = true + await this.options.dispatcher.close() + } +} diff --git a/src/main/feedback/feedback-identity-store.test.ts b/src/main/feedback/feedback-identity-store.test.ts new file mode 100644 index 00000000..b3559556 --- /dev/null +++ b/src/main/feedback/feedback-identity-store.test.ts @@ -0,0 +1,77 @@ +import { + mkdtemp, + readFile, + readdir, + rm, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { FeedbackIdentityStore } from './feedback-identity-store' + +const firstUuid = '00000000-0000-4000-8000-000000000201' +const secondUuid = '00000000-0000-4000-8000-000000000202' +const temporaryDirectories: string[] = [] + +async function identityPath(): Promise { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-feedback-identity-') + ) + temporaryDirectories.push(directory) + return join(directory, 'feedback-identity.json') +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + ) + ) +}) + +describe('FeedbackIdentityStore', () => { + it('coalesces first use and persists one private installation UUID', async () => { + const filePath = await identityPath() + const createUuid = vi.fn(() => firstUuid) + const store = new FeedbackIdentityStore(filePath, createUuid) + + await expect( + Promise.all([ + store.getInstallationId(), + store.getInstallationId() + ]) + ).resolves.toEqual([firstUuid, firstUuid]) + expect(createUuid).toHaveBeenCalledOnce() + expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ + version: 1, + installationId: firstUuid + }) + + const loaded = new FeedbackIdentityStore(filePath, () => secondUuid) + await expect(loaded.getInstallationId()).resolves.toBe(firstUuid) + }) + + it('isolates corrupt state, creates a new identity, and clears it', async () => { + const filePath = await identityPath() + await writeFile(filePath, '{"version":1,"installationId":"broken"}') + const store = new FeedbackIdentityStore(filePath, () => secondUuid) + + await expect(store.getInstallationId()).resolves.toBe(secondUuid) + expect( + (await readdir(join(filePath, '..'))).some((name) => + name.startsWith('feedback-identity.json.corrupt-') + ) + ).toBe(true) + + await store.clear() + await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ + code: 'ENOENT' + }) + expect( + (await readdir(join(filePath, '..'))).some((name) => + name.startsWith('feedback-identity.json.corrupt-') + ) + ).toBe(false) + }) +}) diff --git a/src/main/feedback/feedback-identity-store.ts b/src/main/feedback/feedback-identity-store.ts new file mode 100644 index 00000000..8163ab1a --- /dev/null +++ b/src/main/feedback/feedback-identity-store.ts @@ -0,0 +1,110 @@ +import { randomUUID } from 'node:crypto' +import { readFile, readdir, rm } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { z } from 'zod' +import { + isolateCorruptSettingsFile, + isMissingFileError, + writeJsonFileAtomically +} from '../settings-file-utils' + +const feedbackIdentitySchema = z + .object({ + version: z.literal(1), + installationId: z.uuid() + }) + .strict() + +export class FeedbackIdentityStore { + private installationId?: string + private loadOperation?: Promise + + constructor( + private readonly filePath: string, + private readonly createUuid: () => string = randomUUID + ) {} + + getInstallationId(): Promise { + if (this.installationId) { + return Promise.resolve(this.installationId) + } + if (this.loadOperation) { + return this.loadOperation + } + const operation = this.loadOrCreate().then((installationId) => { + this.installationId = installationId + return installationId + }) + this.loadOperation = operation + void operation.then( + () => { + if (this.loadOperation === operation) { + this.loadOperation = undefined + } + }, + () => { + if (this.loadOperation === operation) { + this.loadOperation = undefined + } + } + ) + return operation + } + + async clear(): Promise { + await this.loadOperation?.catch(() => undefined) + this.installationId = undefined + await rm(this.filePath, { force: true }) + const directory = dirname(this.filePath) + const corruptPrefix = `${basename(this.filePath)}.corrupt-` + let entries: string[] + try { + entries = await readdir(directory) + } catch (error) { + if (isMissingFileError(error)) { + return + } + throw error + } + await Promise.all( + entries + .filter((name) => name.startsWith(corruptPrefix)) + .map((name) => + rm(join(directory, name), { force: true }) + ) + ) + } + + private async loadOrCreate(): Promise { + let serialized: string + try { + serialized = await readFile(this.filePath, 'utf8') + } catch (error) { + if (isMissingFileError(error)) { + return this.create() + } + throw error + } + + try { + return feedbackIdentitySchema.parse( + JSON.parse(serialized) as unknown + ).installationId + } catch { + await isolateCorruptSettingsFile( + this.filePath, + '无法隔离损坏的反馈安装标识' + ) + return this.create() + } + } + + private async create(): Promise { + const installationId = z.uuid().parse(this.createUuid()) + await writeJsonFileAtomically(this.filePath, { + version: 1, + installationId + }) + return installationId + } +} diff --git a/src/main/feedback/feedback-ipc.test.ts b/src/main/feedback/feedback-ipc.test.ts new file mode 100644 index 00000000..e30e0fdd --- /dev/null +++ b/src/main/feedback/feedback-ipc.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest' +import { ipcChannels } from '../../shared/ipc-channels' +import { registerFeedbackIpcHandler } from './feedback-ipc' + +type InvokeHandler = (event: unknown, input: unknown) => unknown + +const electronMocks = vi.hoisted(() => ({ + handlers: new Map(), + handle: vi.fn((channel: string, handler: InvokeHandler) => { + electronMocks.handlers.set(channel, handler) + }), + removeHandler: vi.fn((channel: string) => { + electronMocks.handlers.delete(channel) + }) +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: electronMocks.handle, + removeHandler: electronMocks.removeHandler + } +})) + +describe('feedback IPC', () => { + it('validates input and accepts only the trusted main frame', async () => { + const webContents = { + mainFrame: { + url: 'file:///goodbuddy/index.html' + }, + getURL: vi.fn(() => 'file:///goodbuddy/index.html') + } + const window = { webContents } + const submit = vi.fn(async () => ({ + ok: true as const, + reference: 'GOODBUDDY-000001', + duplicate: false + })) + const remove = registerFeedbackIpcHandler( + window as never, + { submit } + ) + const handler = electronMocks.handlers.get( + ipcChannels.feedbackSubmit + )! + const event = { + sender: webContents, + senderFrame: webContents.mainFrame + } + const input = { + category: 'bug', + title: ' Feedback title ', + description: 'A useful feedback description.', + locale: 'zh-CN', + clientRequestId: + '00000000-0000-4000-8000-000000000501' + } + + await expect(handler(event, input)).resolves.toMatchObject({ + ok: true, + reference: 'GOODBUDDY-000001' + }) + expect(submit).toHaveBeenCalledWith({ + ...input, + title: 'Feedback title' + }) + expect(() => + handler( + { + sender: {}, + senderFrame: webContents.mainFrame + }, + input + ) + ).toThrow('拒绝来自未知窗口的 IPC 请求') + expect(() => + handler(event, { + ...input, + endpoint: 'https://attacker.example/api/v1/feedback' + }) + ).toThrow() + expect(submit).toHaveBeenCalledOnce() + + remove() + expect( + electronMocks.handlers.has(ipcChannels.feedbackSubmit) + ).toBe(false) + }) +}) diff --git a/src/main/feedback/feedback-ipc.ts b/src/main/feedback/feedback-ipc.ts new file mode 100644 index 00000000..fa8477cc --- /dev/null +++ b/src/main/feedback/feedback-ipc.ts @@ -0,0 +1,24 @@ +import { ipcMain, type BrowserWindow } from 'electron' +import { feedbackSubmitInputSchema } from '../../shared/feedback-contracts' +import { ipcChannels } from '../../shared/ipc-channels' +import { assertTrustedSender } from '../trusted-ipc-sender' +import type { FeedbackService } from './feedback-service' + +export function registerFeedbackIpcHandler( + window: BrowserWindow, + feedbackService: Pick +): () => void { + ipcMain.removeHandler(ipcChannels.feedbackSubmit) + ipcMain.handle( + ipcChannels.feedbackSubmit, + (event, input: unknown) => { + assertTrustedSender(event, window) + return feedbackService.submit( + feedbackSubmitInputSchema.parse(input) + ) + } + ) + return () => { + ipcMain.removeHandler(ipcChannels.feedbackSubmit) + } +} diff --git a/src/main/feedback/feedback-screenshot.test.ts b/src/main/feedback/feedback-screenshot.test.ts new file mode 100644 index 00000000..7502e00e --- /dev/null +++ b/src/main/feedback/feedback-screenshot.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + FeedbackScreenshotError, + normalizeFeedbackScreenshot +} from './feedback-screenshot' + +const png = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01 +]) +const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x01]) +const webp = Buffer.from('RIFFxxxxWEBPdata', 'ascii') +const normalizedPng = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x02 +]) + +const imageMocks = vi.hoisted(() => ({ + createFromBuffer: vi.fn() +})) + +vi.mock('electron', () => ({ + nativeImage: { + createFromBuffer: imageMocks.createFromBuffer + } +})) + +beforeEach(() => { + vi.clearAllMocks() + imageMocks.createFromBuffer.mockReturnValue({ + isEmpty: () => false, + getSize: () => ({ width: 640, height: 480 }), + toPNG: () => normalizedPng + }) +}) + +describe('normalizeFeedbackScreenshot', () => { + it.each([ + ['image/png', png], + ['image/jpeg', jpeg], + ['image/webp', webp] + ] as const)('decodes and re-encodes %s as PNG', (mimeType, data) => { + const result = normalizeFeedbackScreenshot({ + data, + mimeType + }) + expect(result).toEqual({ + data: normalizedPng, + width: 640, + height: 480 + }) + expect(result.data).not.toBe(data) + }) + + it('rejects a declared type that does not match the file signature', () => { + expect(() => + normalizeFeedbackScreenshot({ + data: jpeg, + mimeType: 'image/png' + }) + ).toThrowError( + expect.objectContaining>({ + code: 'invalid-submission' + }) + ) + expect(imageMocks.createFromBuffer).not.toHaveBeenCalled() + }) + + it('rejects unsafe dimensions and oversized normalized output', () => { + imageMocks.createFromBuffer.mockReturnValueOnce({ + isEmpty: () => false, + getSize: () => ({ width: 8_193, height: 1 }), + toPNG: () => normalizedPng + }) + expect(() => + normalizeFeedbackScreenshot({ + data: png, + mimeType: 'image/png' + }) + ).toThrowError( + expect.objectContaining>({ + code: 'invalid-submission' + }) + ) + + imageMocks.createFromBuffer.mockReturnValueOnce({ + isEmpty: () => false, + getSize: () => ({ width: 640, height: 480 }), + toPNG: () => Buffer.alloc(5 * 1_024 * 1_024 + 1) + }) + expect(() => + normalizeFeedbackScreenshot({ + data: png, + mimeType: 'image/png' + }) + ).toThrowError( + expect.objectContaining>({ + code: 'screenshot-too-large' + }) + ) + }) +}) diff --git a/src/main/feedback/feedback-screenshot.ts b/src/main/feedback/feedback-screenshot.ts new file mode 100644 index 00000000..8333eb9d --- /dev/null +++ b/src/main/feedback/feedback-screenshot.ts @@ -0,0 +1,86 @@ +import { nativeImage } from 'electron' +import type { FeedbackScreenshotInput } from '../../shared/feedback-contracts' +import { feedbackLimits } from '../../shared/feedback-contracts' +import { detectSupportedImage } from '../file-media-type' + +export class FeedbackScreenshotError extends Error { + constructor( + readonly code: 'invalid-submission' | 'screenshot-too-large' + ) { + super(code) + this.name = 'FeedbackScreenshotError' + } +} + +export type NormalizedFeedbackScreenshot = { + data: Buffer + width: number + height: number +} + +export function normalizeFeedbackScreenshot( + screenshot: FeedbackScreenshotInput +): NormalizedFeedbackScreenshot { + if ( + screenshot.data.byteLength === 0 || + screenshot.data.byteLength > + feedbackLimits.maximumScreenshotBytes + ) { + throw new FeedbackScreenshotError('screenshot-too-large') + } + const input = Buffer.from( + screenshot.data.buffer, + screenshot.data.byteOffset, + screenshot.data.byteLength + ) + try { + if (detectSupportedImage(input).mimeType !== screenshot.mimeType) { + throw new FeedbackScreenshotError('invalid-submission') + } + } catch { + throw new FeedbackScreenshotError('invalid-submission') + } + + let image + try { + image = nativeImage.createFromBuffer(input) + } catch { + throw new FeedbackScreenshotError('invalid-submission') + } + if (image.isEmpty()) { + throw new FeedbackScreenshotError('invalid-submission') + } + const { width, height } = image.getSize() + if ( + !Number.isSafeInteger(width) || + !Number.isSafeInteger(height) || + width <= 0 || + height <= 0 || + width > feedbackLimits.maximumScreenshotDimension || + height > feedbackLimits.maximumScreenshotDimension || + width * height > feedbackLimits.maximumScreenshotPixels + ) { + throw new FeedbackScreenshotError('invalid-submission') + } + + let data: Buffer + try { + data = image.toPNG() + } catch { + throw new FeedbackScreenshotError('invalid-submission') + } + if ( + data.byteLength === 0 || + data.byteLength > feedbackLimits.maximumScreenshotBytes + ) { + throw new FeedbackScreenshotError('screenshot-too-large') + } + try { + if (detectSupportedImage(data).mimeType !== 'image/png') { + throw new FeedbackScreenshotError('invalid-submission') + } + } catch { + throw new FeedbackScreenshotError('invalid-submission') + } + return { data, width, height } +} diff --git a/src/main/feedback/feedback-service.test.ts b/src/main/feedback/feedback-service.test.ts new file mode 100644 index 00000000..17446013 --- /dev/null +++ b/src/main/feedback/feedback-service.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from 'vitest' +import type { FeedbackSubmitInput } from '../../shared/feedback-contracts' +import { FeedbackIdentityStore } from './feedback-identity-store' +import { + FeedbackClientError, + StrictFeedbackHttpClient +} from './feedback-http-client' +import { FeedbackService } from './feedback-service' + +vi.mock('electron', () => ({ + nativeImage: { + createFromBuffer: vi.fn() + } +})) + +const input: FeedbackSubmitInput = { + category: 'feature', + title: 'Feedback title', + description: 'A useful feedback description.', + contactEmail: 'user@example.com', + locale: 'en-US', + clientRequestId: '00000000-0000-4000-8000-000000000401' +} + +function service( + submit: StrictFeedbackHttpClient['submit'], + clear = vi.fn(async () => undefined) +) { + const identityStore = { + getInstallationId: vi.fn( + async () => '00000000-0000-4000-8000-000000000402' + ), + clear + } + const client = { + submit, + close: vi.fn(async () => undefined) + } + return { + service: new FeedbackService({ + appVersion: '0.11.0', + platform: 'win32', + architecture: 'x64', + identityStore: identityStore as unknown as FeedbackIdentityStore, + client: client as unknown as StrictFeedbackHttpClient + }), + identityStore, + client + } +} + +describe('FeedbackService', () => { + it('adds trusted environment and installation values', async () => { + const submit = vi.fn( + async () => ({ + reference: 'GOODBUDDY-000003', + duplicate: false + }) + ) + const fixture = service(submit) + await expect(fixture.service.submit(input)).resolves.toEqual({ + ok: true, + reference: 'GOODBUDDY-000003', + duplicate: false + }) + expect(submit).toHaveBeenCalledWith( + expect.objectContaining({ + schemaVersion: 1, + productKey: 'goodbuddy', + installationId: + '00000000-0000-4000-8000-000000000402', + environment: { + appVersion: '0.11.0', + platform: 'windows', + architecture: 'x64', + locale: 'en-US' + } + }), + undefined, + expect.any(AbortSignal) + ) + }) + + it('coalesces concurrent submissions with the same client request ID', async () => { + let resolve!: (value: { + reference: string + duplicate: boolean + }) => void + const submit = vi.fn( + () => + new Promise((complete) => { + resolve = complete + }) + ) + const fixture = service(submit) + const first = fixture.service.submit(input) + const second = fixture.service.submit(input) + expect(second).toBe(first) + await vi.waitFor(() => expect(submit).toHaveBeenCalledOnce()) + await expect( + fixture.service.submit({ + ...input, + clientRequestId: + '00000000-0000-4000-8000-000000000403' + }) + ).resolves.toEqual({ + ok: false, + error: 'busy' + }) + resolve({ + reference: 'GOODBUDDY-000004', + duplicate: false + }) + await expect(first).resolves.toMatchObject({ ok: true }) + }) + + it('returns bounded error codes and clears the separate identity', async () => { + const submit = vi.fn( + async () => { + throw new FeedbackClientError('rate-limited') + } + ) + const clear = vi.fn(async () => undefined) + const fixture = service(submit, clear) + await expect(fixture.service.submit(input)).resolves.toEqual({ + ok: false, + error: 'rate-limited' + }) + await fixture.service.clear() + expect(clear).toHaveBeenCalledOnce() + }) + + it('blocks new submissions while local feedback data is clearing', async () => { + const submit = vi.fn( + async (_payload, _screenshot, signal) => + new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => reject(new FeedbackClientError('network')), + { once: true } + ) + }) + ) + const fixture = service(submit) + const inFlight = fixture.service.submit(input) + await vi.waitFor(() => expect(submit).toHaveBeenCalledOnce()) + + const clearing = fixture.service.clear() + await expect( + fixture.service.submit({ + ...input, + clientRequestId: + '00000000-0000-4000-8000-000000000404' + }) + ).resolves.toEqual({ + ok: false, + error: 'unavailable' + }) + await expect(inFlight).resolves.toEqual({ + ok: false, + error: 'network' + }) + await clearing + expect(fixture.identityStore.clear).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/feedback/feedback-service.ts b/src/main/feedback/feedback-service.ts new file mode 100644 index 00000000..b6cb2048 --- /dev/null +++ b/src/main/feedback/feedback-service.ts @@ -0,0 +1,206 @@ +import { + feedbackPublicPayloadSchema, + feedbackSubmitInputSchema, + type FeedbackPublicPayload, + type FeedbackSubmitInput, + type FeedbackSubmitResult +} from '../../shared/feedback-contracts' +import { FeedbackIdentityStore } from './feedback-identity-store' +import { + FeedbackClientError, + StrictFeedbackHttpClient +} from './feedback-http-client' +import { + FeedbackScreenshotError, + normalizeFeedbackScreenshot +} from './feedback-screenshot' + +function mapPlatform( + platform: NodeJS.Platform +): FeedbackPublicPayload['environment']['platform'] { + switch (platform) { + case 'win32': + return 'windows' + case 'darwin': + return 'macos' + case 'linux': + return 'linux' + default: + return 'unknown' + } +} + +function mapArchitecture( + architecture: string +): FeedbackPublicPayload['environment']['architecture'] { + return architecture === 'x64' || architecture === 'arm64' + ? architecture + : 'unknown' +} + +export class FeedbackService { + private readonly active = new Map< + string, + { + controller: AbortController + operation: Promise + } + >() + private state: 'ready' | 'clearing' | 'disposed' = 'ready' + private clearOperation?: Promise + + constructor( + private readonly options: { + appVersion: string + platform: NodeJS.Platform + architecture: string + identityStore: FeedbackIdentityStore + client: StrictFeedbackHttpClient + } + ) {} + + submit(input: FeedbackSubmitInput): Promise { + if (this.state !== 'ready') { + return Promise.resolve({ + ok: false, + error: 'unavailable' + }) + } + const parsed = feedbackSubmitInputSchema.parse(input) + const existing = this.active.get(parsed.clientRequestId) + if (existing) { + return existing.operation + } + if (this.active.size > 0) { + return Promise.resolve({ + ok: false, + error: 'busy' + }) + } + const controller = new AbortController() + const operation = this.performSubmit(parsed, controller.signal) + const entry = { controller, operation } + this.active.set(parsed.clientRequestId, entry) + void operation.then( + () => { + if (this.active.get(parsed.clientRequestId) === entry) { + this.active.delete(parsed.clientRequestId) + } + }, + () => { + if (this.active.get(parsed.clientRequestId) === entry) { + this.active.delete(parsed.clientRequestId) + } + } + ) + return operation + } + + clear(): Promise { + if (this.state === 'disposed') { + return Promise.resolve() + } + if (this.clearOperation) { + return this.clearOperation + } + this.state = 'clearing' + const operation = (async () => { + this.abortActive('Feedback data is being cleared') + await Promise.allSettled( + [...this.active.values()].map(({ operation }) => operation) + ) + await this.options.identityStore.clear() + })() + const tracked = operation.finally(() => { + if (this.clearOperation === tracked) { + this.clearOperation = undefined + } + if (this.state === 'clearing') { + this.state = 'ready' + } + }) + this.clearOperation = tracked + return tracked + } + + async dispose(): Promise { + if (this.state === 'disposed') { + return + } + this.state = 'disposed' + this.abortActive('Feedback service is shutting down') + await Promise.allSettled( + [ + this.clearOperation, + ...[...this.active.values()].map(({ operation }) => operation) + ] + ) + await this.options.client.close() + } + + private async performSubmit( + input: FeedbackSubmitInput, + signal: AbortSignal + ): Promise { + try { + const installationId = + await this.options.identityStore.getInstallationId() + const payload = feedbackPublicPayloadSchema.parse({ + schemaVersion: 1, + productKey: 'goodbuddy', + category: input.category, + title: input.title, + description: input.description, + ...(input.contactEmail + ? { contactEmail: input.contactEmail } + : {}), + environment: { + appVersion: this.options.appVersion, + platform: mapPlatform(this.options.platform), + architecture: mapArchitecture( + this.options.architecture + ), + locale: input.locale + }, + installationId, + clientRequestId: input.clientRequestId + }) + const screenshot = input.screenshot + ? normalizeFeedbackScreenshot(input.screenshot).data + : undefined + const result = await this.options.client.submit( + payload, + screenshot, + signal + ) + return { + ok: true, + reference: result.reference, + duplicate: result.duplicate + } + } catch (error) { + if (error instanceof FeedbackClientError) { + return { + ok: false, + error: error.code + } + } + if (error instanceof FeedbackScreenshotError) { + return { + ok: false, + error: error.code + } + } + return { + ok: false, + error: 'unavailable' + } + } + } + + private abortActive(reason: string): void { + for (const { controller } of this.active.values()) { + controller.abort(new Error(reason)) + } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index d3188175..14d95816 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -75,6 +75,13 @@ import { VersionChecker } from './version-checker' import { SpeechModelManager } from './speech/speech-model-manager' import { SpeechTranscriptionService } from './speech/speech-transcription-service' import { GlobalTlsPolicy } from './global-tls-policy' +import { FeedbackIdentityStore } from './feedback/feedback-identity-store' +import { + createStrictFeedbackDispatcher, + StrictFeedbackHttpClient +} from './feedback/feedback-http-client' +import { FeedbackService } from './feedback/feedback-service' +import { registerFeedbackIpcHandler } from './feedback/feedback-ipc' import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts' import { runCleanupBeforeDeadline, @@ -164,6 +171,7 @@ let mainWindow: BrowserWindow | undefined let tray: Tray | undefined let isQuitting = false let removeIpcHandlers: (() => Promise) | undefined +let removeFeedbackIpcHandler: (() => void) | undefined let runtime: AgentRuntimeController | undefined let selectedRuntimeManager: SelectedRuntimeManager | undefined let knowledgeService: KnowledgeService | undefined @@ -171,6 +179,7 @@ let knowledgeGateway: KnowledgeMcpGateway | undefined let assistantDatabase: AssistantDatabase | undefined let browserService: BrowserService | undefined let globalTlsPolicy: GlobalTlsPolicy | undefined +let feedbackService: FeedbackService | undefined let documentOcrBroker: DocumentOcrBroker | undefined let documentOcrModelManager: DocumentOcrModelManager | undefined let embeddingModelManager: EmbeddingModelManager | undefined @@ -717,6 +726,19 @@ if (hasSingleInstanceLock) { const applicationSettingsStore = new ApplicationSettingsStore( join(app.getPath('userData'), 'application-settings.json') ) + const startupFeedbackService = new FeedbackService({ + appVersion: app.getVersion(), + platform: process.platform, + architecture: process.arch, + identityStore: new FeedbackIdentityStore( + join(app.getPath('userData'), 'feedback-identity.json') + ), + client: new StrictFeedbackHttpClient({ + appVersion: app.getVersion(), + dispatcher: createStrictFeedbackDispatcher() + }) + }) + feedbackService = startupFeedbackService const startupEmbeddingModelManager = new EmbeddingModelManager({ userDataDirectory: app.getPath('userData'), fetch: globalThis.fetch, @@ -1206,7 +1228,10 @@ if (hasSingleInstanceLock) { bundledRuntimePaths, reconfigureRuntimes, async () => { - await browserService?.clearSessions() + await Promise.all([ + browserService?.clearSessions(), + startupFeedbackService.clear() + ]) }, browserService, subagentService, @@ -1246,6 +1271,10 @@ if (hasSingleInstanceLock) { }, setCurrentEmbeddingConnection ) + removeFeedbackIpcHandler = registerFeedbackIpcHandler( + mainWindow, + startupFeedbackService + ) loadMainWindow(mainWindow) setImmediate(() => { void repairStaleWindowsNotificationShortcuts({ @@ -1325,6 +1354,8 @@ app.on('before-quit', (event) => { try { const cleanup = settleCleanupPhases([ [ + () => feedbackService?.dispose(), + () => removeFeedbackIpcHandler?.(), () => dshExtensionInstaller?.dispose(), () => removeIpcHandlers?.() ], diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b02e4752..08efc30a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -112,6 +112,7 @@ import { weComChannelSettingsInputSchema } from '../shared/channel-settings-contracts' import { applicationSettingsUpdateSchema } from '../shared/application-settings-contracts' +import { assertTrustedSender } from './trusted-ipc-sender' import { releaseNotesAcknowledgeSchema } from '../shared/release-notes-contracts' import { speechModelActionInputSchema, @@ -675,19 +676,6 @@ const knowledgeUpdateRelationSchema = z }) .strict() -function assertTrustedSender( - event: Electron.IpcMainInvokeEvent, - window: BrowserWindow -): void { - if ( - event.sender !== window.webContents || - event.senderFrame !== window.webContents.mainFrame || - event.senderFrame.url !== window.webContents.getURL() - ) { - throw new Error('拒绝来自未知窗口的 IPC 请求') - } -} - export function sendRemoteProjectSaveProgress( owner: RemoteProjectSaveOwner, progress: RemoteProjectSaveProgress @@ -1121,6 +1109,7 @@ export function registerIpcHandlers( channel !== ipcChannels.conversationNew && channel !== ipcChannels.settingsOpen && channel !== ipcChannels.versionCheckResult && + channel !== ipcChannels.feedbackSubmit && channel !== ipcChannels.weixinBindingChanged && channel !== ipcChannels.remoteChannelActivity && channel !== ipcChannels.remoteProjectSaveProgress && diff --git a/src/main/trusted-ipc-sender.ts b/src/main/trusted-ipc-sender.ts new file mode 100644 index 00000000..c1340d8e --- /dev/null +++ b/src/main/trusted-ipc-sender.ts @@ -0,0 +1,17 @@ +import type { + BrowserWindow, + IpcMainInvokeEvent +} from 'electron' + +export function assertTrustedSender( + event: IpcMainInvokeEvent, + window: BrowserWindow +): void { + if ( + event.sender !== window.webContents || + event.senderFrame !== window.webContents.mainFrame || + event.senderFrame.url !== window.webContents.getURL() + ) { + throw new Error('拒绝来自未知窗口的 IPC 请求') + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index ba77138d..a1bbcc45 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -146,6 +146,10 @@ import type { GlobalShortcutSettingsSnapshot, GlobalShortcutSettingsUpdateResult } from '../shared/shortcut' +import type { + FeedbackSubmitInput, + FeedbackSubmitResult +} from '../shared/feedback-contracts' const desktopApi: DesktopApi = { app: { @@ -451,6 +455,13 @@ const desktopApi: DesktopApi = { ipcRenderer.removeListener(ipcChannels.versionCheckResult, handler) } }, + feedback: { + submit: (input: FeedbackSubmitInput) => + ipcRenderer.invoke( + ipcChannels.feedbackSubmit, + input + ) as Promise + }, shortcuts: { getSettings: () => ipcRenderer.invoke( diff --git a/src/preload/preload-sandbox.test.ts b/src/preload/preload-sandbox.test.ts index 46ffdece..712aaeae 100644 --- a/src/preload/preload-sandbox.test.ts +++ b/src/preload/preload-sandbox.test.ts @@ -117,6 +117,22 @@ describe('sandboxed preload', () => { expect(source).toContain('ipcChannels.releaseNotesAcknowledge') }) + it('exposes only the narrow feedback submission method', () => { + const source = readFileSync( + join(process.cwd(), 'src', 'preload', 'index.ts'), + 'utf8' + ) + const feedback = + source.match( + /feedback: \{(?[\s\S]*?)\n {2}\},\n {2}shortcuts:/u + )?.groups?.body ?? '' + expect(feedback).toContain('submit: (input: FeedbackSubmitInput)') + expect(feedback).toContain('ipcChannels.feedbackSubmit') + expect(feedback).not.toMatch( + /\b(?:url|endpoint|header|cookie|authorization|path)\b/iu + ) + }) + it('exposes only get and validated-update shortcut settings methods', () => { const source = readFileSync( join(process.cwd(), 'src', 'preload', 'index.ts'), diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 1ed7093d..dd92b8e7 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -178,6 +178,13 @@ const api: DesktopApi = { }), onOpenSettings: vi.fn(() => () => {}) }, + feedback: { + submit: vi.fn(async () => ({ + ok: true, + reference: 'GOODBUDDY-000001', + duplicate: false + })) + }, agent: { getStatus: vi.fn(async () => ({ id: 'model' as const, diff --git a/src/renderer/src/FeedbackDialog.test.tsx b/src/renderer/src/FeedbackDialog.test.tsx new file mode 100644 index 00000000..d0ff377b --- /dev/null +++ b/src/renderer/src/FeedbackDialog.test.tsx @@ -0,0 +1,307 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor +} from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DesktopApi } from '../../shared/contracts' +import { FeedbackDialog } from './FeedbackDialog' + +const createObjectUrl = vi.fn(() => 'blob:feedback-screenshot') +const revokeObjectUrl = vi.fn() + +class TestImage { + naturalWidth = 640 + naturalHeight = 480 + onload?: () => void + onerror?: () => void + + set src(_value: string) { + queueMicrotask(() => this.onload?.()) + } +} + +function installApi( + submit: DesktopApi['feedback']['submit'] +): void { + Object.defineProperty(window, 'goodbuddy', { + configurable: true, + value: { + feedback: { submit } + } as unknown as DesktopApi + }) +} + +beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: createObjectUrl + }) + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: revokeObjectUrl + }) + Object.defineProperty(globalThis, 'Image', { + configurable: true, + value: TestImage + }) +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('FeedbackDialog', () => { + it('preserves the draft and request ID across a failed manual retry', async () => { + const submit = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + error: 'network' + }) + .mockResolvedValueOnce({ + ok: true, + reference: 'GOODBUDDY-000007', + duplicate: false + }) + installApi(submit) + render( + + ) + + fireEvent.change(screen.getByLabelText('标题'), { + target: { value: '无法显示魔法笔记页签' } + }) + fireEvent.change(screen.getByLabelText('详细描述'), { + target: { + value: '打开平台功能后,魔法笔记页签没有显示。' + } + }) + const submitButton = screen.getByRole('button', { + name: '提交反馈' + }) + expect(submitButton).toBeEnabled() + fireEvent.click(submitButton) + + expect( + await screen.findByText( + '无法连接反馈服务,请检查网络后重试。' + ) + ).toBeInTheDocument() + expect(screen.getByLabelText('标题')).toHaveValue( + '无法显示魔法笔记页签' + ) + expect(screen.getByLabelText('详细描述')).toHaveValue( + '打开平台功能后,魔法笔记页签没有显示。' + ) + const firstRequestId = + submit.mock.calls[0]![0].clientRequestId + + fireEvent.click( + screen.getByRole('button', { name: '重试提交' }) + ) + expect(await screen.findByText('GOODBUDDY-000007')) + .toBeInTheDocument() + expect(submit).toHaveBeenCalledTimes(2) + expect(submit.mock.calls[1]![0].clientRequestId).toBe( + firstRequestId + ) + }) + + it('validates fields, traps focus, and closes with Escape', () => { + installApi( + vi.fn(async () => ({ + ok: true, + reference: 'GOODBUDDY-000008', + duplicate: false + })) + ) + const onClose = vi.fn() + render( + + ) + const dialog = screen.getByRole('dialog', { + name: '提交反馈' + }) + const category = screen.getByLabelText('反馈类型') + expect(category).toHaveFocus() + fireEvent.change(screen.getByLabelText('标题'), { + target: { value: '有效标题' } + }) + fireEvent.submit(dialog.querySelector('form')!) + expect( + screen.getByText('详细描述至少需要 10 个字符。') + ).toBeInTheDocument() + expect(screen.getByLabelText('详细描述')).toHaveFocus() + fireEvent.change(screen.getByLabelText('详细描述'), { + target: { value: '这是一段足够长的详细描述。' } + }) + + const submitButton = screen.getByRole('button', { + name: '提交反馈' + }) + submitButton.focus() + fireEvent.keyDown(dialog, { key: 'Tab' }) + const closeButton = screen.getByRole('button', { + name: '关闭反馈对话框' + }) + expect(closeButton).toHaveFocus() + fireEvent.keyDown(dialog, { + key: 'Tab', + shiftKey: true + }) + expect(submitButton).toHaveFocus() + + fireEvent.keyDown(dialog, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('accepts one image, keeps text paste native, and releases the preview URL', async () => { + installApi( + vi.fn(async () => ({ + ok: true, + reference: 'GOODBUDDY-000009', + duplicate: false + })) + ) + render( + + ) + const screenshotArea = screen + .getByText('截图(可选)') + .closest('.feedback-screenshot')! + const textPaste = new Event('paste', { + bubbles: true, + cancelable: true + }) + Object.defineProperty(textPaste, 'clipboardData', { + value: { + items: [], + files: [] + } + }) + screenshotArea.dispatchEvent(textPaste) + expect(textPaste.defaultPrevented).toBe(false) + + const file = new File( + [new Uint8Array([0x89, 0x50, 0x4e, 0x47])], + 'screen.png', + { type: 'image/png' } + ) + Object.defineProperty(file, 'arrayBuffer', { + value: async () => + new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer + }) + fireEvent.change(screen.getByLabelText('选择反馈截图'), { + target: { files: [file] } + }) + expect( + await screen.findByAltText('待发送反馈截图预览') + ).toHaveAttribute('src', 'blob:feedback-screenshot') + expect(screen.getByText('640 × 480')).toBeInTheDocument() + + fireEvent.click( + screen.getByRole('button', { name: '移除截图' }) + ) + await waitFor(() => + expect( + screen.queryByAltText('待发送反馈截图预览') + ).not.toBeInTheDocument() + ) + expect(revokeObjectUrl).toHaveBeenCalledWith( + 'blob:feedback-screenshot' + ) + }) + + it('keeps the newest screenshot when file reads finish out of order', async () => { + installApi( + vi.fn(async () => ({ + ok: true, + reference: 'GOODBUDDY-000010', + duplicate: false + })) + ) + let resolveFirst!: (value: ArrayBuffer) => void + const first = new File(['first'], 'first.png', { + type: 'image/png' + }) + Object.defineProperty(first, 'arrayBuffer', { + value: () => + new Promise((resolve) => { + resolveFirst = resolve + }) + }) + const secondBytes = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47 + ]) + const second = new File([secondBytes], 'second.png', { + type: 'image/png' + }) + Object.defineProperty(second, 'arrayBuffer', { + value: async () => secondBytes.buffer + }) + createObjectUrl.mockReturnValueOnce('blob:newest-screenshot') + render( + + ) + const input = screen.getByLabelText('选择反馈截图') + fireEvent.change(input, { + target: { files: [first] } + }) + fireEvent.change(input, { + target: { files: [second] } + }) + expect( + await screen.findByAltText('待发送反馈截图预览') + ).toHaveAttribute('src', 'blob:newest-screenshot') + + await act(async () => { + resolveFirst(new Uint8Array([0x89, 0x50]).buffer) + }) + expect( + screen.getByAltText('待发送反馈截图预览') + ).toHaveAttribute('src', 'blob:newest-screenshot') + expect(createObjectUrl).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/FeedbackDialog.tsx b/src/renderer/src/FeedbackDialog.tsx new file mode 100644 index 00000000..ca6e021b --- /dev/null +++ b/src/renderer/src/FeedbackDialog.tsx @@ -0,0 +1,703 @@ +import { + CheckCircle2, + Copy, + ImagePlus, + Trash2, + X +} from 'lucide-react' +import { + useEffect, + useId, + useRef, + useState +} from 'react' +import { useTranslation } from 'react-i18next' +import type { AppInfo } from '../../shared/contracts' +import { + feedbackCategories, + feedbackCategorySchema, + feedbackContactEmailSchema, + feedbackLimits, + feedbackScreenshotMimeTypeSchema, + type FeedbackCategory, + type FeedbackScreenshotInput, + type FeedbackSubmissionErrorCode +} from '../../shared/feedback-contracts' +import { activateModalFocus, trapTabFocus } from './dialog-focus' + +type FeedbackDialogProps = { + appInfo: AppInfo + onClose: () => void +} + +type FieldErrors = { + title?: string + description?: string + contactEmail?: string +} + +type ScreenshotDraft = FeedbackScreenshotInput & { + objectUrl: string + width: number + height: number + size: number +} + +function formatScreenshotBytes(bytes: number): string { + return bytes >= 1_024 * 1_024 + ? `${(bytes / (1_024 * 1_024)).toFixed(1)} MB` + : `${Math.max(1, Math.ceil(bytes / 1_024))} KB` +} + +function imageDimensions( + objectUrl: string +): Promise<{ width: number; height: number }> { + return new Promise((resolve, reject) => { + const image = new Image() + image.onload = () => + resolve({ + width: image.naturalWidth, + height: image.naturalHeight + }) + image.onerror = () => reject(new Error('Image could not be decoded')) + image.src = objectUrl + }) +} + +export function FeedbackDialog({ + appInfo, + onClose +}: FeedbackDialogProps): React.JSX.Element { + const { i18n, t } = useTranslation('settingsSections') + const locale = + (i18n.resolvedLanguage ?? i18n.language) === 'en-US' + ? 'en-US' + : 'zh-CN' + const titleId = useId() + const descriptionId = useId() + const dialogRef = useRef(null) + const categoryRef = useRef(null) + const titleRef = useRef(null) + const descriptionRef = useRef(null) + const emailRef = useRef(null) + const fileInputRef = useRef(null) + const screenshotUrlRef = useRef(undefined) + const screenshotSelectionRef = useRef(0) + const pendingScreenshotUrlsRef = useRef(new Set()) + const [category, setCategory] = useState('bug') + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [contactEmail, setContactEmail] = useState('') + const [screenshot, setScreenshot] = useState() + const [screenshotProcessing, setScreenshotProcessing] = + useState(false) + const [screenshotError, setScreenshotError] = useState() + const [fieldErrors, setFieldErrors] = useState({}) + const [submitting, setSubmitting] = useState(false) + const [submissionError, setSubmissionError] = + useState() + const [reference, setReference] = useState() + const [copied, setCopied] = useState(false) + const [clientRequestId] = useState(() => crypto.randomUUID()) + + useEffect( + () => activateModalFocus(() => categoryRef.current), + [] + ) + + useEffect( + () => () => { + screenshotSelectionRef.current += 1 + for (const objectUrl of pendingScreenshotUrlsRef.current) { + URL.revokeObjectURL(objectUrl) + } + pendingScreenshotUrlsRef.current.clear() + if (screenshotUrlRef.current) { + URL.revokeObjectURL(screenshotUrlRef.current) + } + }, + [] + ) + + const releaseScreenshotUrl = (): void => { + if (screenshotUrlRef.current) { + URL.revokeObjectURL(screenshotUrlRef.current) + screenshotUrlRef.current = undefined + } + } + + const removeScreenshot = (): void => { + screenshotSelectionRef.current += 1 + for (const objectUrl of pendingScreenshotUrlsRef.current) { + URL.revokeObjectURL(objectUrl) + } + pendingScreenshotUrlsRef.current.clear() + releaseScreenshotUrl() + setScreenshot(undefined) + setScreenshotProcessing(false) + setScreenshotError(undefined) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + + const setScreenshotFile = async (file: File): Promise => { + const selection = screenshotSelectionRef.current + 1 + screenshotSelectionRef.current = selection + setScreenshotProcessing(true) + setScreenshotError(undefined) + const mimeType = feedbackScreenshotMimeTypeSchema.safeParse( + file.type + ) + if (!mimeType.success) { + setScreenshotError(t('feedback.screenshot.unsupported')) + setScreenshotProcessing(false) + return + } + if ( + file.size === 0 || + file.size > feedbackLimits.maximumScreenshotBytes + ) { + setScreenshotError(t('feedback.screenshot.tooLarge')) + setScreenshotProcessing(false) + return + } + let objectUrl: string | undefined + try { + const data = new Uint8Array(await file.arrayBuffer()) + if (selection !== screenshotSelectionRef.current) { + return + } + if (data.byteLength > feedbackLimits.maximumScreenshotBytes) { + setScreenshotError(t('feedback.screenshot.tooLarge')) + return + } + objectUrl = URL.createObjectURL(file) + pendingScreenshotUrlsRef.current.add(objectUrl) + const { width, height } = await imageDimensions(objectUrl) + if (selection !== screenshotSelectionRef.current) { + pendingScreenshotUrlsRef.current.delete(objectUrl) + URL.revokeObjectURL(objectUrl) + return + } + if ( + width <= 0 || + height <= 0 || + width > feedbackLimits.maximumScreenshotDimension || + height > feedbackLimits.maximumScreenshotDimension || + width * height > feedbackLimits.maximumScreenshotPixels + ) { + throw new Error('Image dimensions are unsupported') + } + pendingScreenshotUrlsRef.current.delete(objectUrl) + releaseScreenshotUrl() + screenshotUrlRef.current = objectUrl + setScreenshot({ + data, + mimeType: mimeType.data, + objectUrl, + width, + height, + size: file.size + }) + } catch { + if (objectUrl) { + pendingScreenshotUrlsRef.current.delete(objectUrl) + URL.revokeObjectURL(objectUrl) + } + if (selection === screenshotSelectionRef.current) { + setScreenshotError(t('feedback.screenshot.invalid')) + } + } finally { + if (selection === screenshotSelectionRef.current) { + setScreenshotProcessing(false) + } + } + } + + const validate = (): FieldErrors => { + const errors: FieldErrors = {} + if (!title.trim()) { + errors.title = t('feedback.validation.titleRequired') + } + if ( + description.trim().length < + feedbackLimits.minimumDescriptionCharacters + ) { + errors.description = t( + 'feedback.validation.descriptionMinimum' + ) + } + if ( + contactEmail.trim() && + !feedbackContactEmailSchema.safeParse(contactEmail.trim()) + .success + ) { + errors.contactEmail = t('feedback.validation.emailInvalid') + } + return errors + } + + const canSubmit = + title.trim().length > 0 && + description.trim().length >= + feedbackLimits.minimumDescriptionCharacters && + (!contactEmail.trim() || + feedbackContactEmailSchema.safeParse(contactEmail.trim()) + .success) && + !screenshotProcessing + + const focusFirstError = (errors: FieldErrors): void => { + if (errors.title) { + titleRef.current?.focus() + } else if (errors.description) { + descriptionRef.current?.focus() + } else if (errors.contactEmail) { + emailRef.current?.focus() + } + } + + const submit = async (): Promise => { + if (screenshotProcessing) { + return + } + const errors = validate() + setFieldErrors(errors) + if (Object.keys(errors).length > 0) { + focusFirstError(errors) + return + } + setSubmitting(true) + setSubmissionError(undefined) + try { + const result = await window.goodbuddy.feedback.submit({ + category, + title, + description, + ...(contactEmail.trim() + ? { contactEmail: contactEmail.trim() } + : {}), + locale, + clientRequestId, + ...(screenshot + ? { + screenshot: { + data: screenshot.data, + mimeType: screenshot.mimeType + } + } + : {}) + }) + if (result.ok) { + setReference(result.reference) + } else { + setSubmissionError(result.error) + } + } catch { + setSubmissionError('network') + } finally { + setSubmitting(false) + } + } + + const close = (): void => { + if (!submitting) { + onClose() + } + } + + const platformLabel = (): string => { + switch (appInfo.platform) { + case 'win32': + return t('feedback.environment.platforms.windows') + case 'darwin': + return t('feedback.environment.platforms.macos') + case 'linux': + return t('feedback.environment.platforms.linux') + default: + return t('feedback.environment.platforms.unknown') + } + } + + return ( +
{ + if (event.key === 'Escape' && !submitting) { + event.preventDefault() + close() + return + } + trapTabFocus(event, dialogRef.current) + }} + ref={dialogRef} + role="dialog" + tabIndex={-1} + > +
+
+
+ {t('feedback.dialog.title')} +

+ {t('feedback.dialog.description')} +

+
+ +
+ + {reference ? ( +
+
+ ) : ( +
{ + event.preventDefault() + void submit() + }} + > +
+ + + + +