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))
}
}
}