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
@@ -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',