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:
@@ -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: (
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
>
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user