feat: add in-app feedback submission

GoodBuddy previously had no direct way to send feedback from the desktop app. The About & Updates page now opens a bilingual, accessible feedback dialog for problems, feature requests, experience notes, and other feedback, with an optional email address and one screenshot.

Submissions use the narrow preload and trusted IPC boundary, a fixed production endpoint with strict TLS, bounded screenshot processing, an isolated resettable installation ID, preserved drafts, and idempotent manual retries. GoodBuddy does not automatically attach conversations, logs, files, settings, or credentials.

Release note: 现在可在“关于与更新”中直接提交问题、建议与体验反馈;邮箱和截图均可选,提交失败时会保留草稿供重试。
This commit is contained in:
mesalogo
2026-08-25 08:01:42 +08:00
parent efecf88af7
commit 098f21b47a
31 changed files with 3445 additions and 58 deletions
+1
View File
@@ -80,6 +80,7 @@
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、ZIP 或本地目录导入、切换和删除。
- [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。
- [x] **版本检查与镜像节点**:在“关于与更新”中选择 GitHub(默认)或镜像节点;手动检查、启动时检查和下载页使用同一选择,并只读取固定可信的发布索引,不自动下载或安装。
- [x] **应用内反馈**:在“关于与更新”中直接提交问题、建议或体验反馈,支持可选邮箱和单张截图;失败后保留草稿并按同一请求编号重试,不自动附加对话、日志、文件、配置或凭据。
- [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。
### 开源、构建与发布
+1
View File
@@ -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 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。
@@ -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 <trusted-feedback-origin>/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/<version>`。
- 不发送 `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 窄窗口、焦点与背景隔离、截图预览和
真实生产提交验收。
@@ -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<FeedbackRequest>(
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<FeedbackRequest>(
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<FeedbackRequest>(
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<FeedbackRequest>(
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<FeedbackRequest>(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<FeedbackRequest>(
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()
})
})
+294
View File
@@ -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<Uint8Array> & {
destroy(error?: Error): void
}
type FeedbackHttpResponse = {
statusCode: number
headers: Record<string, string | string[] | undefined>
body: FeedbackResponseBody
}
type FeedbackRequestOptions = {
dispatcher: Dispatcher
method: 'POST'
headers: Record<string, string>
body: string | FormData
signal: AbortSignal
headersTimeout: number
bodyTimeout: number
idempotent: false
blocking: true
}
export type FeedbackRequest = (
url: URL,
options: FeedbackRequestOptions
) => Promise<FeedbackHttpResponse>
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<Buffer> {
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<FeedbackPublicResponse> {
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<string, string> = {
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<void> {
if (this.closed) {
return
}
this.closed = true
await this.options.dispatcher.close()
}
}
@@ -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<string> {
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)
})
})
@@ -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<string>
constructor(
private readonly filePath: string,
private readonly createUuid: () => string = randomUUID
) {}
getInstallationId(): Promise<string> {
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<void> {
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<string> {
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<string> {
const installationId = z.uuid().parse(this.createUuid())
await writeJsonFileAtomically(this.filePath, {
version: 1,
installationId
})
return installationId
}
}
+88
View File
@@ -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<string, InvokeHandler>(),
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)
})
})
+24
View File
@@ -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<FeedbackService, 'submit'>
): () => 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)
}
}
@@ -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<Partial<FeedbackScreenshotError>>({
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<Partial<FeedbackScreenshotError>>({
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<Partial<FeedbackScreenshotError>>({
code: 'screenshot-too-large'
})
)
})
})
+86
View File
@@ -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 }
}
+166
View File
@@ -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<StrictFeedbackHttpClient['submit']>(
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<StrictFeedbackHttpClient['submit']>(
() =>
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<StrictFeedbackHttpClient['submit']>(
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<StrictFeedbackHttpClient['submit']>(
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()
})
})
+206
View File
@@ -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<FeedbackSubmitResult>
}
>()
private state: 'ready' | 'clearing' | 'disposed' = 'ready'
private clearOperation?: Promise<void>
constructor(
private readonly options: {
appVersion: string
platform: NodeJS.Platform
architecture: string
identityStore: FeedbackIdentityStore
client: StrictFeedbackHttpClient
}
) {}
submit(input: FeedbackSubmitInput): Promise<FeedbackSubmitResult> {
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<void> {
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<void> {
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<FeedbackSubmitResult> {
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))
}
}
}
+32 -1
View File
@@ -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<void>) | 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?.()
],
+2 -13
View File
@@ -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 &&
+17
View File
@@ -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 请求')
}
}
+11
View File
@@ -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<FeedbackSubmitResult>
},
shortcuts: {
getSettings: () =>
ipcRenderer.invoke(
+16
View File
@@ -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: \{(?<body>[\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'),
+7
View File
@@ -178,6 +178,13 @@ const api: DesktopApi = {
}),
onOpenSettings: vi.fn(() => () => {})
},
feedback: {
submit: vi.fn<DesktopApi['feedback']['submit']>(async () => ({
ok: true,
reference: 'GOODBUDDY-000001',
duplicate: false
}))
},
agent: {
getStatus: vi.fn<DesktopApi['agent']['getStatus']>(async () => ({
id: 'model' as const,
+307
View File
@@ -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<DesktopApi['feedback']['submit']>()
.mockResolvedValueOnce({
ok: false,
error: 'network'
})
.mockResolvedValueOnce({
ok: true,
reference: 'GOODBUDDY-000007',
duplicate: false
})
installApi(submit)
render(
<FeedbackDialog
appInfo={{
name: 'GoodBuddy',
version: '0.11.0',
platform: 'win32',
arch: 'x64',
shortcut: 'Ctrl+Shift+Space'
}}
onClose={vi.fn()}
/>
)
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<DesktopApi['feedback']['submit']>(async () => ({
ok: true,
reference: 'GOODBUDDY-000008',
duplicate: false
}))
)
const onClose = vi.fn()
render(
<FeedbackDialog
appInfo={{
name: 'GoodBuddy',
version: '0.11.0',
platform: 'linux',
arch: 'arm64',
shortcut: 'Ctrl+Shift+Space'
}}
onClose={onClose}
/>
)
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<DesktopApi['feedback']['submit']>(async () => ({
ok: true,
reference: 'GOODBUDDY-000009',
duplicate: false
}))
)
render(
<FeedbackDialog
appInfo={{
name: 'GoodBuddy',
version: '0.11.0',
platform: 'darwin',
arch: 'arm64',
shortcut: 'Command+Shift+Space'
}}
onClose={vi.fn()}
/>
)
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<DesktopApi['feedback']['submit']>(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<ArrayBuffer>((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(
<FeedbackDialog
appInfo={{
name: 'GoodBuddy',
version: '0.11.0',
platform: 'win32',
arch: 'x64',
shortcut: 'Ctrl+Shift+Space'
}}
onClose={vi.fn()}
/>
)
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()
})
})
+703
View File
@@ -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<HTMLDivElement>(null)
const categoryRef = useRef<HTMLSelectElement>(null)
const titleRef = useRef<HTMLInputElement>(null)
const descriptionRef = useRef<HTMLTextAreaElement>(null)
const emailRef = useRef<HTMLInputElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const screenshotUrlRef = useRef<string | undefined>(undefined)
const screenshotSelectionRef = useRef(0)
const pendingScreenshotUrlsRef = useRef(new Set<string>())
const [category, setCategory] = useState<FeedbackCategory>('bug')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [contactEmail, setContactEmail] = useState('')
const [screenshot, setScreenshot] = useState<ScreenshotDraft>()
const [screenshotProcessing, setScreenshotProcessing] =
useState(false)
const [screenshotError, setScreenshotError] = useState<string>()
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
const [submitting, setSubmitting] = useState(false)
const [submissionError, setSubmissionError] =
useState<FeedbackSubmissionErrorCode>()
const [reference, setReference] = useState<string>()
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<void> => {
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<void> => {
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 (
<div
aria-describedby={descriptionId}
aria-labelledby={titleId}
aria-modal="true"
className="feedback-dialog-backdrop"
onKeyDown={(event) => {
if (event.key === 'Escape' && !submitting) {
event.preventDefault()
close()
return
}
trapTabFocus(event, dialogRef.current)
}}
ref={dialogRef}
role="dialog"
tabIndex={-1}
>
<div className="feedback-dialog">
<header className="feedback-dialog__header">
<div>
<strong id={titleId}>{t('feedback.dialog.title')}</strong>
<p id={descriptionId}>
{t('feedback.dialog.description')}
</p>
</div>
<button
aria-label={t('feedback.actions.close')}
className="icon-button"
disabled={submitting}
onClick={close}
type="button"
>
<X aria-hidden="true" size={17} />
</button>
</header>
{reference ? (
<div
aria-live="polite"
className="feedback-dialog__success"
>
<CheckCircle2 aria-hidden="true" size={34} />
<div>
<strong>{t('feedback.success.title')}</strong>
<p>{t('feedback.success.description')}</p>
</div>
<code>{reference}</code>
<button
className="secondary-button"
onClick={() => {
void navigator.clipboard
.writeText(reference)
.then(() => setCopied(true))
.catch(() => setCopied(false))
}}
type="button"
>
<Copy aria-hidden="true" size={14} />
{copied
? t('feedback.actions.copied')
: t('feedback.actions.copyReference')}
</button>
<button
className="primary-button"
onClick={close}
type="button"
>
{t('feedback.actions.done')}
</button>
</div>
) : (
<form
className="feedback-dialog__form"
noValidate
onSubmit={(event) => {
event.preventDefault()
void submit()
}}
>
<div className="feedback-dialog__fields">
<label className="field">
<span>{t('feedback.fields.category')}</span>
<select
disabled={submitting}
onChange={(event) =>
setCategory(
feedbackCategorySchema.parse(event.target.value)
)
}
ref={categoryRef}
value={category}
>
{feedbackCategories.map((value) => (
<option key={value} value={value}>
{t(`feedback.categories.${value}`)}
</option>
))}
</select>
</label>
<label className="field">
<span>{t('feedback.fields.title')}</span>
<input
aria-label={t('feedback.fields.title')}
aria-invalid={Boolean(fieldErrors.title)}
disabled={submitting}
maxLength={feedbackLimits.maximumTitleCharacters}
onBlur={() =>
setFieldErrors((current) => ({
...current,
title: title.trim()
? undefined
: t('feedback.validation.titleRequired')
}))
}
onChange={(event) => {
setTitle(event.target.value)
setFieldErrors((current) => ({
...current,
title: undefined
}))
}}
placeholder={t('feedback.fields.titlePlaceholder')}
ref={titleRef}
required
type="text"
value={title}
/>
<small>
{t('feedback.fields.characterCount', {
count: title.length,
maximum: feedbackLimits.maximumTitleCharacters
})}
</small>
{fieldErrors.title && (
<small role="alert">{fieldErrors.title}</small>
)}
</label>
<label className="field">
<span>{t('feedback.fields.description')}</span>
<textarea
aria-label={t('feedback.fields.description')}
aria-invalid={Boolean(fieldErrors.description)}
disabled={submitting}
maxLength={
feedbackLimits.maximumDescriptionCharacters
}
onBlur={() =>
setFieldErrors((current) => ({
...current,
description:
description.trim().length >=
feedbackLimits.minimumDescriptionCharacters
? undefined
: t(
'feedback.validation.descriptionMinimum'
)
}))
}
onChange={(event) => {
setDescription(event.target.value)
setFieldErrors((current) => ({
...current,
description: undefined
}))
}}
placeholder={t(
'feedback.fields.descriptionPlaceholder'
)}
ref={descriptionRef}
required
rows={6}
value={description}
/>
<small>
{t('feedback.fields.characterCount', {
count: description.length,
maximum:
feedbackLimits.maximumDescriptionCharacters
})}
</small>
{fieldErrors.description && (
<small role="alert">
{fieldErrors.description}
</small>
)}
</label>
<label className="field">
<span>{t('feedback.fields.contactEmail')}</span>
<input
aria-label={t('feedback.fields.contactEmail')}
aria-invalid={Boolean(fieldErrors.contactEmail)}
disabled={submitting}
maxLength={feedbackLimits.maximumEmailCharacters}
onBlur={() =>
setFieldErrors((current) => ({
...current,
contactEmail:
!contactEmail.trim() ||
feedbackContactEmailSchema.safeParse(
contactEmail.trim()
).success
? undefined
: t('feedback.validation.emailInvalid')
}))
}
onChange={(event) => {
setContactEmail(event.target.value)
setFieldErrors((current) => ({
...current,
contactEmail: undefined
}))
}}
placeholder={t('feedback.fields.emailPlaceholder')}
ref={emailRef}
type="email"
value={contactEmail}
/>
<small>{t('feedback.fields.emailHelp')}</small>
{fieldErrors.contactEmail && (
<small role="alert">
{fieldErrors.contactEmail}
</small>
)}
</label>
<div
className="feedback-screenshot"
onPaste={(event) => {
const imageItem = Array.from(
event.clipboardData.items
).find(
(item) =>
item.kind === 'file' &&
item.type.startsWith('image/')
)
const file =
imageItem?.getAsFile() ??
Array.from(event.clipboardData.files).find(
(candidate) =>
candidate.type.startsWith('image/')
)
if (file) {
event.preventDefault()
void setScreenshotFile(file)
}
}}
>
<div className="feedback-screenshot__heading">
<div>
<strong>{t('feedback.screenshot.title')}</strong>
<small>{t('feedback.screenshot.help')}</small>
</div>
<input
accept="image/png,image/jpeg,image/webp"
aria-label={t('feedback.screenshot.fileInput')}
hidden
onChange={(event) => {
const file = event.currentTarget.files?.[0]
event.currentTarget.value = ''
if (file) {
void setScreenshotFile(file)
}
}}
ref={fileInputRef}
type="file"
/>
<button
className="secondary-button"
disabled={submitting || screenshotProcessing}
onClick={() => fileInputRef.current?.click()}
type="button"
>
<ImagePlus aria-hidden="true" size={14} />
{screenshotProcessing
? t('feedback.actions.processingScreenshot')
: screenshot
? t('feedback.actions.replaceScreenshot')
: t('feedback.actions.addScreenshot')}
</button>
</div>
{screenshot && (
<div className="feedback-screenshot__preview">
<img
alt={t('feedback.screenshot.previewAlt')}
src={screenshot.objectUrl}
/>
<div>
<span>
{screenshot.width} × {screenshot.height}
</span>
<small>
{formatScreenshotBytes(screenshot.size)}
</small>
</div>
<button
aria-label={t(
'feedback.actions.removeScreenshot'
)}
className="secondary-button"
disabled={submitting || screenshotProcessing}
onClick={removeScreenshot}
type="button"
>
<Trash2 aria-hidden="true" size={14} />
{t('feedback.actions.removeScreenshot')}
</button>
</div>
)}
<small>{t('feedback.screenshot.privacy')}</small>
{screenshotError && (
<small role="alert">{screenshotError}</small>
)}
</div>
<div className="feedback-environment">
<strong>{t('feedback.environment.title')}</strong>
<dl>
<div>
<dt>{t('feedback.environment.version')}</dt>
<dd>{appInfo.version}</dd>
</div>
<div>
<dt>{t('feedback.environment.system')}</dt>
<dd>
{platformLabel()} · {appInfo.arch}
</dd>
</div>
<div>
<dt>{t('feedback.environment.locale')}</dt>
<dd>{locale}</dd>
</div>
</dl>
</div>
<p className="feedback-dialog__privacy">
{t('feedback.privacy')}
</p>
{submissionError && (
<div
className="feedback-dialog__error"
role="alert"
>
<strong>{t('feedback.errors.title')}</strong>
<p>
{t(`feedback.errors.${submissionError}`)}
</p>
</div>
)}
</div>
<footer className="feedback-dialog__actions">
<button
className="secondary-button"
disabled={submitting}
onClick={close}
type="button"
>
{t('feedback.actions.cancel')}
</button>
<button
className="primary-button"
disabled={!canSubmit || submitting}
type="submit"
>
{submitting
? t('feedback.actions.submitting')
: submissionError
? t('feedback.actions.retry')
: t('feedback.actions.submit')}
</button>
</footer>
</form>
)}
</div>
</div>
)
}
+16
View File
@@ -726,6 +726,22 @@ describe('SettingsPanel runtime files', () => {
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
app: {
getInfo: vi.fn(async () => ({
name: 'GoodBuddy',
version: '0.11.0',
platform: 'win32',
arch: 'x64',
shortcut: 'Ctrl+Shift+Space'
}))
},
feedback: {
submit: vi.fn(async () => ({
ok: true as const,
reference: 'GOODBUDDY-000001',
duplicate: false
}))
},
settings: {
getRuntime,
updateRuntime,
@@ -69,6 +69,13 @@ describe('UpdateSettingsSection', () => {
shortcut: 'Ctrl+Shift+Space'
}))
},
feedback: {
submit: vi.fn(async () => ({
ok: true as const,
reference: 'GOODBUDDY-000001',
duplicate: false
}))
},
updates: {
getSettings: vi.fn(async () => ({
...applicationSettings
@@ -125,6 +132,25 @@ describe('UpdateSettingsSection', () => {
expect(
screen.getByText('GoodBuddy-0.9.0-windows-x64-setup.exe')
).toBeInTheDocument()
const feedbackTrigger = screen.getByRole('button', {
name: '提交反馈'
})
feedbackTrigger.focus()
fireEvent.click(feedbackTrigger)
const dialog = screen.getByRole('dialog', {
name: '提交反馈'
})
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(screen.getByLabelText('反馈类型')).toHaveFocus()
expect(
screen.getByText(/不会自动发送对话、日志、文件/)
).toBeInTheDocument()
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(
screen.queryByRole('dialog', { name: '提交反馈' })
).not.toBeInTheDocument()
expect(feedbackTrigger).toHaveFocus()
})
it('replaces Electron fetch wrappers with an actionable network error', async () => {
@@ -177,4 +203,41 @@ describe('UpdateSettingsSection', () => {
)
expect(alert).not.toHaveTextContent('Error invoking remote method')
})
it('keeps feedback available when update settings are unavailable', async () => {
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
app: {
getInfo: vi.fn(async () => ({
name: 'GoodBuddy',
version: '0.11.0',
platform: 'linux',
arch: 'arm64',
shortcut: 'Ctrl+Shift+Space'
}))
},
feedback: {
submit: vi.fn(async () => ({
ok: true as const,
reference: 'GOODBUDDY-000010',
duplicate: false
}))
}
} as unknown as DesktopApi
})
render(<UpdateSettingsSection />)
const feedbackTrigger = await screen.findByRole('button', {
name: '提交反馈'
})
expect(feedbackTrigger).toBeEnabled()
fireEvent.click(feedbackTrigger)
expect(
screen.getByRole('dialog', { name: '提交反馈' })
).toBeInTheDocument()
expect(
screen.queryByText('反馈服务当前不可用')
).not.toBeInTheDocument()
})
})
+52 -11
View File
@@ -1,4 +1,8 @@
import { ExternalLink, RefreshCw } from 'lucide-react'
import {
ExternalLink,
MessageSquarePlus,
RefreshCw
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
@@ -11,6 +15,7 @@ import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
import { FeedbackDialog } from './FeedbackDialog'
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) {
@@ -48,23 +53,34 @@ export function UpdateSettingsSection(): React.JSX.Element {
const [checking, setChecking] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string>()
const [feedbackOpen, setFeedbackOpen] = useState(false)
useEffect(() => {
const updates = window.goodbuddy.updates
let active = true
void (async () => {
if (!updates) {
throw new Error(t('updates.errors.serviceUnavailable'))
void window.goodbuddy.app
.getInfo()
.then((info) => {
if (active) {
setAppInfo(info)
}
})
.catch(() => undefined)
if (!updates) {
queueMicrotask(() => {
if (active) {
setError(t('updates.errors.serviceUnavailable'))
}
})
return () => {
active = false
}
return Promise.all([
updates.getSettings(),
window.goodbuddy.app.getInfo()
])
})()
.then(([nextSettings, info]) => {
}
void updates
.getSettings()
.then((nextSettings) => {
if (active) {
setSettings(nextSettings)
setAppInfo(info)
}
})
.catch((reason: unknown) => {
@@ -293,7 +309,32 @@ export function UpdateSettingsSection(): React.JSX.Element {
</p>
</article>
)}
<article className="capability-card feedback-entry-card">
<div className="capability-card__header">
<div>
<strong>{t('feedback.entry.title')}</strong>
<small>{t('feedback.entry.description')}</small>
</div>
</div>
<div className="feedback-entry-card__actions">
<button
className="primary-button"
disabled={!appInfo}
onClick={() => setFeedbackOpen(true)}
type="button"
>
<MessageSquarePlus aria-hidden="true" size={14} />
{t('feedback.entry.action')}
</button>
</div>
</article>
</section>
{feedbackOpen && appInfo && (
<FeedbackDialog
appInfo={appInfo}
onClose={() => setFeedbackOpen(false)}
/>
)}
</>
)
}
@@ -574,6 +574,113 @@ export const settingsSections = {
assignedTo: 'Assigned to',
deleteAria: 'Delete {{name}}'
},
feedback: {
entry: {
title: 'Help improve GoodBuddy',
description:
'Report a problem, suggest a feature, or share your experience. We will handle it in our independent feedback system.',
action: 'Submit feedback'
},
dialog: {
title: 'Submit feedback',
description:
'Tell us what happened or what you would like improved. Your draft is preserved if submission fails.'
},
categories: {
bug: 'Problem',
feature: 'Feature request',
experience: 'User experience',
other: 'Other'
},
fields: {
category: 'Feedback type',
title: 'Title',
titlePlaceholder: 'Summarize the problem or suggestion',
description: 'Detailed description',
descriptionPlaceholder:
'Describe what happened, what you expected, and any steps needed to reproduce it.',
contactEmail: 'Contact email (optional)',
emailPlaceholder: 'user@example.com',
emailHelp:
'Used only if we need to contact you for more information.',
characterCount: '{{count}} / {{maximum}} characters'
},
screenshot: {
title: 'Screenshot (optional)',
help:
'Select or paste one PNG, JPEG, or WebP image, up to 5 MB.',
fileInput: 'Select a feedback screenshot',
previewAlt: 'Preview of the feedback screenshot to be sent',
privacy:
'Screenshots may contain personal information visible on screen. Review the image before sending.',
unsupported: 'Only PNG, JPEG, and WebP images are supported.',
tooLarge: 'The screenshot must be 5 MB or smaller.',
invalid:
'The screenshot could not be read or its dimensions exceed the limit.'
},
environment: {
title: 'Application information to be sent',
version: 'GoodBuddy version',
system: 'Operating system',
locale: 'Interface language',
platforms: {
windows: 'Windows',
macos: 'macOS',
linux: 'Linux',
unknown: 'Unknown system'
}
},
privacy:
'GoodBuddy will send the feedback type, title, description, optional email, app version, operating system, architecture, interface language, and any screenshot you add. Conversations, logs, files, project paths, model settings, and credentials are never attached automatically.',
validation: {
titleRequired: 'Enter a feedback title.',
descriptionMinimum:
'The detailed description must contain at least 10 characters.',
emailInvalid: 'Enter a valid contact email or leave it blank.'
},
actions: {
close: 'Close feedback dialog',
cancel: 'Cancel',
submit: 'Submit feedback',
submitting: 'Submitting…',
retry: 'Retry submission',
addScreenshot: 'Add screenshot',
processingScreenshot: 'Reading screenshot…',
replaceScreenshot: 'Replace screenshot',
removeScreenshot: 'Remove screenshot',
copyReference: 'Copy feedback reference',
copied: 'Copied',
done: 'Done'
},
success: {
title: 'Feedback submitted',
description:
'Thank you for your feedback. Save this reference for future communication.'
},
errors: {
title: 'Feedback was not submitted',
'invalid-submission':
'The submission does not meet the feedback service requirements. Check the fields and screenshot, then try again.',
'incompatible-client':
'This GoodBuddy version does not match the feedback service configuration. Update the app and try again.',
unavailable:
'The feedback service is temporarily unavailable. Try again later.',
busy:
'Another feedback submission is in progress. Wait for it to finish, then try again.',
'screenshot-too-large':
'The screenshot or request is too large. Remove it or choose a smaller image, then try again.',
'rate-limited':
'Feedback is being submitted too frequently. Try again later.',
'service-error':
'The feedback service has a temporary problem. Try again later.',
network:
'Could not connect to the feedback service. Check your network and try again.',
timeout:
'The feedback service connection timed out. Check your network and try again.',
'invalid-response':
'The feedback service returned an invalid result. Try again later.'
}
},
updates: {
label: 'Update settings',
errors: {
@@ -535,6 +535,103 @@ export const settingsSections = {
assignedTo: '分配给',
deleteAria: '删除 {{name}}'
},
feedback: {
entry: {
title: '帮助改进 GoodBuddy',
description:
'报告问题、提出建议或反馈使用体验,我们会在独立反馈系统中处理。',
action: '提交反馈'
},
dialog: {
title: '提交反馈',
description:
'告诉我们遇到的问题或希望改进的地方。提交失败时,当前内容会保留以便重试。'
},
categories: {
bug: '问题',
feature: '功能建议',
experience: '使用体验',
other: '其他'
},
fields: {
category: '反馈类型',
title: '标题',
titlePlaceholder: '简要概括问题或建议',
description: '详细描述',
descriptionPlaceholder:
'请描述发生了什么、你期望的结果,以及必要的复现步骤。',
contactEmail: '联系邮箱(可选)',
emailPlaceholder: 'user@example.com',
emailHelp: '仅在需要进一步了解情况时用于联系你。',
characterCount: '已输入 {{count}} / {{maximum}} 个字符'
},
screenshot: {
title: '截图(可选)',
help: '选择或粘贴一张 PNG、JPEG 或 WebP 图片,最大 5 MB。',
fileInput: '选择反馈截图',
previewAlt: '待发送反馈截图预览',
privacy:
'截图可能包含画面中可见的个人信息,请在发送前检查。',
unsupported: '仅支持 PNG、JPEG 或 WebP 图片。',
tooLarge: '截图必须小于或等于 5 MB。',
invalid: '无法读取该截图,或图片尺寸超出限制。'
},
environment: {
title: '将发送的应用信息',
version: 'GoodBuddy 版本',
system: '操作系统',
locale: '界面语言',
platforms: {
windows: 'Windows',
macos: 'macOS',
linux: 'Linux',
unknown: '未知系统'
}
},
privacy:
'将发送反馈类型、标题、描述、可选邮箱、GoodBuddy 版本、操作系统、架构、界面语言,以及你主动添加的截图。不会自动发送对话、日志、文件、项目路径、模型配置或凭据。',
validation: {
titleRequired: '请输入反馈标题。',
descriptionMinimum: '详细描述至少需要 10 个字符。',
emailInvalid: '请输入有效的联系邮箱,或将其留空。'
},
actions: {
close: '关闭反馈对话框',
cancel: '取消',
submit: '提交反馈',
submitting: '正在提交…',
retry: '重试提交',
addScreenshot: '添加截图',
processingScreenshot: '正在读取截图…',
replaceScreenshot: '替换截图',
removeScreenshot: '移除截图',
copyReference: '复制反馈编号',
copied: '已复制',
done: '完成'
},
success: {
title: '反馈已提交',
description:
'感谢你的反馈。请保存下面的编号,以便后续沟通。'
},
errors: {
title: '未能提交反馈',
'invalid-submission':
'提交内容不符合反馈服务要求,请检查字段和截图后重试。',
'incompatible-client':
'当前 GoodBuddy 版本与反馈服务配置不匹配,请更新应用后重试。',
unavailable: '反馈服务暂时不可用,请稍后重试。',
busy: '已有一条反馈正在提交,请等待完成后再试。',
'screenshot-too-large':
'截图或请求内容过大,请移除截图或更换较小图片后重试。',
'rate-limited': '提交过于频繁,请稍后再试。',
'service-error': '反馈服务暂时异常,请稍后重试。',
network: '无法连接反馈服务,请检查网络后重试。',
timeout: '连接反馈服务超时,请检查网络后重试。',
'invalid-response':
'反馈服务返回了无效结果,请稍后重试。'
}
},
updates: {
label: '更新设置',
errors: {
+309
View File
@@ -8438,6 +8438,274 @@ details.settings-section > :not(summary) + :not(summary) {
gap: var(--space-2);
}
.feedback-entry-card__actions {
display: flex;
justify-content: flex-start;
padding-top: var(--space-1);
}
.feedback-entry-card__actions .primary-button,
.feedback-dialog__actions button,
.feedback-dialog__success button,
.feedback-screenshot button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
.feedback-dialog-backdrop {
position: fixed;
z-index: var(--z-dialog);
display: grid;
padding: var(--space-6);
background: var(--overlay-backdrop);
inset: 0;
place-items: center;
}
.feedback-dialog {
display: grid;
width: min(600px, 100%);
max-height: calc(100vh - 48px);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
overflow: hidden;
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
grid-template-rows: auto minmax(0, 1fr);
}
.feedback-dialog__header {
display: flex;
align-items: flex-start;
padding: var(--space-5);
border-bottom: 1px solid var(--border-subtle);
gap: var(--space-4);
}
.feedback-dialog__header > div {
min-width: 0;
flex: 1;
}
.feedback-dialog__header strong {
color: var(--text-primary);
font-size: var(--font-section-title);
}
.feedback-dialog__header p {
margin: var(--space-1) 0 0;
color: var(--text-secondary);
font-size: var(--font-caption);
line-height: 1.55;
}
.feedback-dialog__header .icon-button {
width: 30px;
height: 30px;
margin: -5px -5px 0 0;
flex: 0 0 auto;
}
.feedback-dialog__form {
display: grid;
min-height: 0;
grid-template-rows: minmax(0, 1fr) auto;
}
.feedback-dialog__fields {
display: flex;
min-height: 0;
flex-direction: column;
padding: var(--space-5);
overflow-y: auto;
gap: var(--space-4);
scrollbar-gutter: stable;
}
.feedback-dialog__fields textarea {
min-height: 116px;
resize: vertical;
}
.feedback-dialog__fields .field > small[role='alert'],
.feedback-screenshot > small[role='alert'] {
color: var(--danger);
}
.feedback-screenshot {
display: flex;
flex-direction: column;
padding: var(--space-3);
border: 1px dashed var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-subtle);
gap: var(--space-2);
}
.feedback-screenshot__heading {
display: flex;
align-items: flex-start;
gap: var(--space-3);
}
.feedback-screenshot__heading > div {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 2px;
}
.feedback-screenshot strong,
.feedback-environment > strong {
color: var(--text-primary);
font-size: var(--font-body);
}
.feedback-screenshot small {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
}
.feedback-screenshot__preview {
display: grid;
align-items: center;
padding: var(--space-2);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-raised);
grid-template-columns: 84px minmax(0, 1fr) auto;
gap: var(--space-3);
}
.feedback-screenshot__preview img {
width: 84px;
height: 58px;
border-radius: 7px;
object-fit: cover;
}
.feedback-screenshot__preview > div {
display: flex;
min-width: 0;
flex-direction: column;
color: var(--text-secondary);
font-size: var(--font-caption);
gap: 2px;
}
.feedback-environment {
display: flex;
flex-direction: column;
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-subtle);
gap: var(--space-2);
}
.feedback-environment dl {
display: grid;
margin: 0;
gap: var(--space-1);
}
.feedback-environment dl > div {
display: grid;
color: var(--text-secondary);
font-size: var(--font-caption);
grid-template-columns: minmax(120px, 0.8fr) minmax(0, 1.2fr);
gap: var(--space-3);
}
.feedback-environment dt,
.feedback-environment dd {
margin: 0;
}
.feedback-environment dd {
color: var(--text-primary);
overflow-wrap: anywhere;
}
.feedback-dialog__privacy {
padding: var(--space-3);
border-radius: var(--radius-control);
margin: 0;
background: var(--accent-subtle);
color: var(--text-secondary);
font-size: var(--font-caption);
line-height: 1.6;
}
.feedback-dialog__error {
padding: var(--space-3);
border: 1px solid var(--danger-border);
border-radius: var(--radius-control);
background: var(--danger-subtle);
color: var(--danger);
}
.feedback-dialog__error strong {
font-size: var(--font-body);
}
.feedback-dialog__error p {
margin: var(--space-1) 0 0;
font-size: var(--font-caption);
line-height: 1.5;
}
.feedback-dialog__actions {
display: flex;
justify-content: flex-end;
padding: var(--space-3) var(--space-5);
border-top: 1px solid var(--border-subtle);
background: var(--surface-subtle);
gap: var(--space-2);
}
.feedback-dialog__success {
display: flex;
align-items: center;
flex-direction: column;
padding: calc(var(--space-6) * 2) var(--space-6);
color: var(--success);
text-align: center;
gap: var(--space-4);
}
.feedback-dialog__success > div {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.feedback-dialog__success strong {
color: var(--text-primary);
font-size: var(--font-section-title);
}
.feedback-dialog__success p {
max-width: 420px;
margin: 0;
color: var(--text-secondary);
font-size: var(--font-caption);
line-height: 1.55;
}
.feedback-dialog__success code {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-control);
background: var(--success-subtle);
color: var(--success);
font-size: var(--font-body);
font-weight: 700;
}
.field.update-settings__source {
display: grid;
min-height: 36px;
@@ -8533,6 +8801,47 @@ details.settings-section > :not(summary) + :not(summary) {
}
@media (max-width: 720px) {
.feedback-dialog-backdrop {
padding: var(--space-4);
}
.feedback-dialog {
max-height: calc(100vh - 32px);
}
.feedback-dialog__header,
.feedback-dialog__fields {
padding: var(--space-4);
}
.feedback-dialog__actions {
padding: var(--space-3) var(--space-4);
}
.feedback-screenshot__heading,
.feedback-screenshot__preview {
align-items: stretch;
grid-template-columns: 72px minmax(0, 1fr);
}
.feedback-screenshot__heading {
flex-direction: column;
}
.feedback-screenshot__preview img {
width: 72px;
height: 52px;
}
.feedback-screenshot__preview > button {
grid-column: 1 / -1;
}
.feedback-environment dl > div {
grid-template-columns: 1fr;
gap: 2px;
}
.field.update-settings__source {
align-items: stretch;
grid-template-columns: 1fr;
+9
View File
@@ -140,6 +140,10 @@ import type {
RemoteProjectSaveProgress,
RemoteProjectSaveRequest
} from './remote-project-candidate-contracts'
import type {
FeedbackSubmitInput,
FeedbackSubmitResult
} from './feedback-contracts'
export type {
KnowledgeTaskError,
KnowledgeTaskItem,
@@ -1592,6 +1596,11 @@ export type DesktopApi = {
listener: (result: VersionCheckResult) => void
) => () => void
}
feedback: {
submit: (
input: FeedbackSubmitInput
) => Promise<FeedbackSubmitResult>
}
shortcuts?: {
getSettings: () => Promise<GlobalShortcutSettingsSnapshot>
updateSettings: (
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import {
feedbackCategories,
feedbackLimits,
feedbackPublicPayloadSchema,
feedbackPublicResponseSchema,
feedbackSubmitInputSchema
} from './feedback-contracts'
const clientRequestId = '00000000-0000-4000-8000-000000000101'
const installationId = '00000000-0000-4000-8000-000000000102'
describe('feedback contracts', () => {
it.each(feedbackCategories)('accepts the %s category', (category) => {
expect(
feedbackSubmitInputSchema.parse({
category,
title: ' Feedback title ',
description: ' A useful feedback description. ',
locale: 'zh-CN',
clientRequestId
})
).toMatchObject({
category,
title: 'Feedback title',
description: 'A useful feedback description.'
})
})
it('rejects unknown fields, invalid contact details, and oversized screenshots', () => {
expect(() =>
feedbackSubmitInputSchema.parse({
category: 'bug',
title: 'Feedback title',
description: 'A useful feedback description.',
locale: 'zh-CN',
clientRequestId,
endpoint: 'https://attacker.example'
})
).toThrow()
expect(() =>
feedbackSubmitInputSchema.parse({
category: 'bug',
title: 'Feedback title',
description: 'A useful feedback description.',
contactEmail: 'not-an-email',
locale: 'zh-CN',
clientRequestId
})
).toThrow()
expect(() =>
feedbackSubmitInputSchema.parse({
category: 'bug',
title: 'Feedback title',
description: 'A useful feedback description.',
locale: 'zh-CN',
clientRequestId,
screenshot: {
data: new Uint8Array(
feedbackLimits.maximumScreenshotBytes + 1
),
mimeType: 'image/png'
}
})
).toThrow()
})
it('matches the deployed version 1 public payload and response', () => {
expect(
feedbackPublicPayloadSchema.parse({
schemaVersion: 1,
productKey: 'goodbuddy',
category: 'experience',
title: 'Feedback title',
description: 'A useful feedback description.',
contactEmail: 'user@example.com',
environment: {
appVersion: '0.11.0',
platform: 'windows',
architecture: 'x64',
locale: 'en-US'
},
installationId,
clientRequestId
})
).toMatchObject({
schemaVersion: 1,
productKey: 'goodbuddy'
})
expect(
feedbackPublicResponseSchema.parse({
reference: 'GOODBUDDY-1000000',
duplicate: false
})
).toEqual({
reference: 'GOODBUDDY-1000000',
duplicate: false
})
})
})
+145
View File
@@ -0,0 +1,145 @@
import { z } from 'zod'
export const feedbackCategories = [
'bug',
'feature',
'experience',
'other'
] as const
export const feedbackLimits = {
maximumTitleCharacters: 120,
minimumDescriptionCharacters: 10,
maximumDescriptionCharacters: 5_000,
maximumEmailCharacters: 254,
maximumScreenshotBytes: 5 * 1_024 * 1_024,
maximumScreenshotDimension: 8_192,
maximumScreenshotPixels: 32_000_000
} as const
export const feedbackCategorySchema = z.enum(feedbackCategories)
export const feedbackLocaleSchema = z.enum(['zh-CN', 'en-US'])
export const feedbackScreenshotMimeTypeSchema = z.enum([
'image/png',
'image/jpeg',
'image/webp'
])
export const feedbackContactEmailSchema = z
.email()
.max(feedbackLimits.maximumEmailCharacters)
export const feedbackScreenshotInputSchema = z
.object({
data: z
.instanceof(Uint8Array)
.refine(
(data) =>
data.byteLength > 0 &&
data.byteLength <= feedbackLimits.maximumScreenshotBytes,
'Screenshot is empty or exceeds the size limit'
),
mimeType: feedbackScreenshotMimeTypeSchema
})
.strict()
const feedbackContentSchema = z
.object({
category: feedbackCategorySchema,
title: z
.string()
.trim()
.min(1)
.max(feedbackLimits.maximumTitleCharacters),
description: z
.string()
.trim()
.min(feedbackLimits.minimumDescriptionCharacters)
.max(feedbackLimits.maximumDescriptionCharacters),
contactEmail: feedbackContactEmailSchema.optional()
})
.strict()
export const feedbackSubmitInputSchema = feedbackContentSchema
.extend({
locale: feedbackLocaleSchema,
clientRequestId: z.uuid(),
screenshot: feedbackScreenshotInputSchema.optional()
})
.strict()
export const feedbackEnvironmentSchema = z
.object({
appVersion: z.string().trim().min(1).max(64),
platform: z.enum(['windows', 'macos', 'linux', 'unknown']),
architecture: z.enum(['x64', 'arm64', 'unknown']),
locale: feedbackLocaleSchema
})
.strict()
export const feedbackPublicPayloadSchema = feedbackContentSchema
.extend({
schemaVersion: z.literal(1),
productKey: z.literal('goodbuddy'),
environment: feedbackEnvironmentSchema,
installationId: z.uuid(),
clientRequestId: z.uuid()
})
.strict()
export const feedbackPublicResponseSchema = z
.object({
reference: z.string().regex(/^[A-Z0-9]{2,12}-\d{6,}$/u),
duplicate: z.boolean()
})
.strict()
export const feedbackSubmissionErrorCodes = [
'invalid-submission',
'incompatible-client',
'unavailable',
'busy',
'screenshot-too-large',
'rate-limited',
'service-error',
'network',
'timeout',
'invalid-response'
] as const
export const feedbackSubmitResultSchema = z.discriminatedUnion('ok', [
z
.object({
ok: z.literal(true),
reference: feedbackPublicResponseSchema.shape.reference,
duplicate: z.boolean()
})
.strict(),
z
.object({
ok: z.literal(false),
error: z.enum(feedbackSubmissionErrorCodes)
})
.strict()
])
export type FeedbackCategory = z.infer<typeof feedbackCategorySchema>
export type FeedbackScreenshotInput = z.infer<
typeof feedbackScreenshotInputSchema
>
export type FeedbackSubmitInput = z.infer<
typeof feedbackSubmitInputSchema
>
export type FeedbackEnvironment = z.infer<
typeof feedbackEnvironmentSchema
>
export type FeedbackPublicPayload = z.infer<
typeof feedbackPublicPayloadSchema
>
export type FeedbackPublicResponse = z.infer<
typeof feedbackPublicResponseSchema
>
export type FeedbackSubmissionErrorCode =
(typeof feedbackSubmissionErrorCodes)[number]
export type FeedbackSubmitResult = z.infer<
typeof feedbackSubmitResultSchema
>
+1
View File
@@ -65,6 +65,7 @@ export const ipcChannels = {
versionCheck: 'application:update:check',
versionOpenReleasePage: 'application:update:open-release-page',
versionCheckResult: 'application:update:result',
feedbackSubmit: 'feedback:submit',
releaseNotesGetPending: 'application:release-notes:get-pending',
releaseNotesAcknowledge: 'application:release-notes:acknowledge',
speechModelsGet: 'settings:speech-models:get',