feat: add bilingual release notes

This commit is contained in:
lofyer
2026-08-11 20:59:26 +08:00
parent 6942bef567
commit 44d30b428d
26 changed files with 1344 additions and 42 deletions
+35 -4
View File
@@ -73,11 +73,12 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 4,
version: 5,
checkUpdatesOnStartup: false,
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
})
expect(
(await readdir(directory)).filter((name) => name.endsWith('.tmp'))
@@ -166,6 +167,35 @@ describe('ApplicationSettingsStore', () => {
})
})
it('migrates version 4 settings with no release notes acknowledged', async () => {
const { filePath, store } = await createStore()
await writeFile(
filePath,
JSON.stringify({
version: 4,
checkUpdatesOnStartup: false,
magicNotesEnabled: true,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative'
}),
'utf8'
)
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBeNull()
await store.setLastSeenReleaseNotesVersion('0.8.18')
await expect(
new ApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 5,
checkUpdatesOnStartup: false,
magicNotesEnabled: true,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative',
lastSeenReleaseNotesVersion: '0.8.18'
})
})
it('strictly rejects incomplete full settings', () => {
for (const input of [
{},
@@ -290,11 +320,12 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 4,
version: 5,
checkUpdatesOnStartup: false,
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
})
})
+86 -24
View File
@@ -13,13 +13,14 @@ import {
applicationSettingsUpdateSchema,
type ApplicationSettings
} from '../shared/application-settings-contracts'
import { releaseVersionSchema } from '../shared/release-notes-contracts'
export {
applicationSettingsSchema,
applicationSettingsUpdateSchema
} from '../shared/application-settings-contracts'
export type { ApplicationSettings } from '../shared/application-settings-contracts'
const CURRENT_SETTINGS_VERSION = 4
const CURRENT_SETTINGS_VERSION = 5
const legacyStoredApplicationSettingsSchema = z
.object({
@@ -45,9 +46,16 @@ const versionThreeStoredApplicationSettingsSchema = z
})
.strict()
const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
.extend({
version: z.literal(4)
})
.strict()
const storedApplicationSettingsSchema = applicationSettingsSchema
.extend({
version: z.literal(CURRENT_SETTINGS_VERSION)
version: z.literal(CURRENT_SETTINGS_VERSION),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
@@ -73,6 +81,7 @@ function isMissingFile(error: unknown): boolean {
export class ApplicationSettingsStore {
private settings?: StoredApplicationSettings
private settingsLoad?: Promise<StoredApplicationSettings>
private updateQueue: Promise<void> = Promise.resolve()
constructor(private readonly filePath: string) {}
@@ -97,6 +106,15 @@ export class ApplicationSettingsStore {
if (this.settings) {
return this.settings
}
if (!this.settingsLoad) {
this.settingsLoad = this.readStored().finally(() => {
this.settingsLoad = undefined
})
}
return this.settingsLoad
}
private async readStored(): Promise<StoredApplicationSettings> {
try {
const contents = await readFile(this.filePath, 'utf8')
let parsed: unknown
@@ -106,19 +124,31 @@ export class ApplicationSettingsStore {
await this.isolateCorruptFile()
this.settings = {
version: CURRENT_SETTINGS_VERSION,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings
}
return this.settings
}
const result = storedApplicationSettingsSchema.safeParse(parsed)
if (!result.success) {
const versionFourResult =
versionFourStoredApplicationSettingsSchema.safeParse(parsed)
if (versionFourResult.success) {
this.settings = {
...versionFourResult.data,
version: CURRENT_SETTINGS_VERSION,
lastSeenReleaseNotesVersion: null
}
return this.settings
}
const versionThreeResult =
versionThreeStoredApplicationSettingsSchema.safeParse(parsed)
if (versionThreeResult.success) {
this.settings = {
...versionThreeResult.data,
version: CURRENT_SETTINGS_VERSION,
magicNoteCommentFormat: 'combined'
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
}
return this.settings
}
@@ -129,7 +159,8 @@ export class ApplicationSettingsStore {
...versionTwoResult.data,
version: CURRENT_SETTINGS_VERSION,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
}
return this.settings
}
@@ -142,13 +173,15 @@ export class ApplicationSettingsStore {
legacyResult.data.checkUpdatesOnStartup,
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
}
return this.settings
}
await this.isolateCorruptFile()
this.settings = {
version: CURRENT_SETTINGS_VERSION,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings
}
return this.settings
@@ -162,6 +195,7 @@ export class ApplicationSettingsStore {
}
this.settings = {
version: CURRENT_SETTINGS_VERSION,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings
}
}
@@ -178,6 +212,32 @@ export class ApplicationSettingsStore {
}
}
async getLastSeenReleaseNotesVersion(): Promise<string | null> {
return (await this.loadStored()).lastSeenReleaseNotesVersion
}
private async persist(next: StoredApplicationSettings): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true })
const temporaryPath =
`${this.filePath}.${process.pid}.` +
`${randomBytes(6).toString('hex')}.tmp`
try {
await writeFile(
temporaryPath,
`${JSON.stringify(next, null, 2)}\n`,
{
encoding: 'utf8',
mode: 0o600,
flag: 'wx'
}
)
await rename(temporaryPath, this.filePath)
} finally {
await rm(temporaryPath, { force: true })
}
this.settings = next
}
update(input: unknown): Promise<ApplicationSettings> {
const operation = this.updateQueue.then(async () => {
const updates = applicationSettingsUpdateSchema.parse(input)
@@ -187,25 +247,7 @@ export class ApplicationSettingsStore {
...updates,
version: CURRENT_SETTINGS_VERSION
}
await mkdir(dirname(this.filePath), { recursive: true })
const temporaryPath =
`${this.filePath}.${process.pid}.` +
`${randomBytes(6).toString('hex')}.tmp`
try {
await writeFile(
temporaryPath,
`${JSON.stringify(next, null, 2)}\n`,
{
encoding: 'utf8',
mode: 0o600,
flag: 'wx'
}
)
await rename(temporaryPath, this.filePath)
} finally {
await rm(temporaryPath, { force: true })
}
this.settings = next
await this.persist(next)
return {
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
magicNotesEnabled: next.magicNotesEnabled,
@@ -219,4 +261,24 @@ export class ApplicationSettingsStore {
)
return operation
}
setLastSeenReleaseNotesVersion(version: unknown): Promise<void> {
const operation = this.updateQueue.then(async () => {
const parsedVersion = releaseVersionSchema.parse(version)
const current = await this.loadStored()
if (current.lastSeenReleaseNotesVersion === parsedVersion) {
return
}
await this.persist({
...current,
version: CURRENT_SETTINGS_VERSION,
lastSeenReleaseNotesVersion: parsedVersion
})
})
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation
}
}
+10 -1
View File
@@ -71,6 +71,7 @@ import { DocumentParsingSettingsStore } from './document-parsing-settings-store'
import { DocumentOcrModelManager } from './document-ocr-model-manager'
import { DocumentOcrBroker } from './document-ocr-broker'
import { DocumentParsingService } from './document-parsing-service'
import { ReleaseNotesService } from './release-notes-service'
const shortcut = 'CommandOrControl+Shift+Space'
const mainModuleDirectory = dirname(fileURLToPath(import.meta.url))
@@ -346,6 +347,13 @@ if (hasSingleInstanceLock) {
const applicationSettingsStore = new ApplicationSettingsStore(
join(app.getPath('userData'), 'application-settings.json')
)
const releaseNotesService = new ReleaseNotesService({
currentVersion: app.getVersion(),
filePath: app.isPackaged
? join(process.resourcesPath, 'release-notes.json')
: join(app.getAppPath(), 'resources', 'release-notes.json'),
settingsStore: applicationSettingsStore
})
const documentParsingSettingsStore =
new DocumentParsingSettingsStore(
join(app.getPath('userData'), 'document-parsing-settings.json')
@@ -545,7 +553,8 @@ if (hasSingleInstanceLock) {
launchWechatSidecar,
documentParsingService,
documentOcrModelManager,
documentOcrBroker
documentOcrBroker,
releaseNotesService
)
loadMainWindow(mainWindow)
+25 -1
View File
@@ -66,6 +66,7 @@ import {
weComChannelSettingsInputSchema
} from '../shared/channel-settings-contracts'
import { applicationSettingsUpdateSchema } from '../shared/application-settings-contracts'
import { releaseNotesAcknowledgeSchema } from '../shared/release-notes-contracts'
import {
speechModelActionInputSchema,
speechModelSelectionInputSchema
@@ -189,6 +190,7 @@ import type { EmbeddingIndexCoordinator } from './knowledge/embedding-index-coor
import type { DocumentParsingService } from './document-parsing-service'
import type { DocumentOcrModelManager } from './document-ocr-model-manager'
import type { DocumentOcrBroker } from './document-ocr-broker'
import type { ReleaseNotesService } from './release-notes-service'
import { OpenAIEmbeddingClient } from './knowledge/openai-embedding-client'
import {
magicNotePlainText,
@@ -600,7 +602,8 @@ export function registerIpcHandlers(
launchWechatSidecar?: WechatSidecarLauncher,
documentParsingService?: DocumentParsingService,
documentOcrModelManager?: DocumentOcrModelManager,
documentOcrBroker?: DocumentOcrBroker
documentOcrBroker?: DocumentOcrBroker,
releaseNotesService?: ReleaseNotesService
): () => Promise<void> {
const activeRequests = new Map<string, AbortController>()
const pendingAgentQuestions = new Map<
@@ -2742,6 +2745,27 @@ export function registerIpcHandlers(
await shell.openExternal(GOODBUDDY_RELEASES_URL)
})
ipcMain.handle(ipcChannels.releaseNotesGetPending, (event) => {
assertTrustedSender(event, window)
if (!releaseNotesService) {
throw new Error('版本更新说明服务不可用')
}
return releaseNotesService.getPending()
})
ipcMain.handle(
ipcChannels.releaseNotesAcknowledge,
async (event, input: unknown) => {
assertTrustedSender(event, window)
if (!releaseNotesService) {
throw new Error('版本更新说明服务不可用')
}
await releaseNotesService.acknowledge(
releaseNotesAcknowledgeSchema.parse(input)
)
}
)
const requireEmbeddingProvider = async (): Promise<OpenAIEmbeddingClient> => {
const settings = await settingsStore.getResolvedSettings()
if (!settings.knowledgeEmbeddingEnabled) {
+134
View File
@@ -0,0 +1,134 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { ApplicationSettingsStore } from './application-settings-store'
import { ReleaseNotesService } from './release-notes-service'
const temporaryDirectories: string[] = []
const localizedNotes = (label: string) => ({
'zh-CN': {
features: [`${label} 功能`],
fixes: [`${label} 修复`]
},
'en-US': {
features: [`${label} feature`],
fixes: [`${label} fix`]
}
})
async function createService(
currentVersion: string
): Promise<{
filePath: string
service: ReleaseNotesService
settingsStore: ApplicationSettingsStore
}> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-release-notes-'))
temporaryDirectories.push(directory)
const filePath = join(directory, 'release-notes.json')
await writeFile(
filePath,
JSON.stringify({
formatVersion: 1,
releases: [
{
version: '0.8.12',
releasedAt: '2026-08-04',
notes: localizedNotes('0.8.12')
},
{
version: '0.8.18',
releasedAt: '2026-08-11',
notes: localizedNotes('0.8.18')
}
]
}),
'utf8'
)
const settingsStore = new ApplicationSettingsStore(
join(directory, 'application-settings.json')
)
return {
filePath,
settingsStore,
service: new ReleaseNotesService({
currentVersion,
filePath,
settingsStore
})
}
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
describe('ReleaseNotesService', () => {
it('shows only the current release on a fresh installation', async () => {
const { service } = await createService('0.8.18')
await expect(service.getPending()).resolves.toMatchObject({
currentVersion: '0.8.18',
releases: [{ version: '0.8.18' }]
})
})
it('shows every unseen release through the current version', async () => {
const { service, settingsStore } = await createService('0.8.18')
await settingsStore.setLastSeenReleaseNotesVersion('0.8.11')
await expect(service.getPending()).resolves.toMatchObject({
releases: [{ version: '0.8.12' }, { version: '0.8.18' }]
})
})
it('persists acknowledgement and does not show the release again', async () => {
const { service, settingsStore } = await createService('0.8.18')
await service.acknowledge({ version: '0.8.18' })
await expect(service.getPending()).resolves.toEqual({
currentVersion: '0.8.18',
releases: []
})
await expect(
settingsStore.getLastSeenReleaseNotesVersion()
).resolves.toBe('0.8.18')
})
it('rejects acknowledgement for another or unknown version', async () => {
const { service } = await createService('0.8.18')
await expect(
service.acknowledge({ version: '0.8.12' })
).rejects.toThrow('Only the current release notes can be acknowledged')
await expect(
service.acknowledge({ version: '0.8.19' })
).rejects.toThrow('Only the current release notes can be acknowledged')
})
it('does not reopen release notes after an application downgrade', async () => {
const { service, settingsStore } = await createService('0.8.12')
await settingsStore.setLastSeenReleaseNotesVersion('0.8.18')
await expect(service.getPending()).resolves.toEqual({
currentVersion: '0.8.12',
releases: []
})
})
it('rejects an oversized release-notes resource with a bounded read', async () => {
const { filePath, service } = await createService('0.8.18')
await writeFile(filePath, ' '.repeat(128 * 1024 + 1), 'utf8')
await expect(service.getPending()).rejects.toThrow(
'Release notes exceed the size limit'
)
})
})
+93
View File
@@ -0,0 +1,93 @@
import { open } from 'node:fs/promises'
import {
releaseNotesAcknowledgeSchema,
releaseNotesFileSchema,
type ReleaseNote,
type ReleaseNotesSnapshot
} from '../shared/release-notes-contracts'
import type { ApplicationSettingsStore } from './application-settings-store'
import { compareStrictSemVer } from './version-checker'
const maximumReleaseNotesBytes = 128 * 1024
export class ReleaseNotesService {
private releases?: ReleaseNote[]
private releaseLoad?: Promise<ReleaseNote[]>
constructor(
private readonly dependencies: {
currentVersion: string
filePath: string
settingsStore: ApplicationSettingsStore
}
) {}
private async loadReleases(): Promise<ReleaseNote[]> {
if (this.releases) {
return this.releases
}
if (!this.releaseLoad) {
this.releaseLoad = this.readReleases().finally(() => {
this.releaseLoad = undefined
})
}
return this.releaseLoad
}
private async readReleases(): Promise<ReleaseNote[]> {
const handle = await open(this.dependencies.filePath, 'r')
try {
const buffer = Buffer.alloc(maximumReleaseNotesBytes + 1)
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
if (bytesRead > maximumReleaseNotesBytes) {
throw new Error('Release notes exceed the size limit')
}
const parsed = releaseNotesFileSchema.parse(
JSON.parse(buffer.toString('utf8', 0, bytesRead)) as unknown
)
this.releases = [...parsed.releases].sort((left, right) =>
compareStrictSemVer(left.version, right.version)
)
return this.releases
} finally {
await handle.close()
}
}
async getPending(): Promise<ReleaseNotesSnapshot> {
const releases = await this.loadReleases()
const currentVersion = this.dependencies.currentVersion
const lastSeenVersion =
await this.dependencies.settingsStore.getLastSeenReleaseNotesVersion()
const pending = releases.filter((release) => {
const comparedWithCurrent = compareStrictSemVer(
release.version,
currentVersion
)
if (comparedWithCurrent > 0) {
return false
}
return lastSeenVersion
? compareStrictSemVer(release.version, lastSeenVersion) > 0
: comparedWithCurrent === 0
})
return {
currentVersion,
releases: pending
}
}
async acknowledge(input: unknown): Promise<void> {
const { version } = releaseNotesAcknowledgeSchema.parse(input)
if (version !== this.dependencies.currentVersion) {
throw new Error('Only the current release notes can be acknowledged')
}
const releases = await this.loadReleases()
if (!releases.some((release) => release.version === version)) {
throw new Error('Current release notes are unavailable')
}
await this.dependencies.settingsStore.setLastSeenReleaseNotesVersion(
version
)
}
}
+28
View File
@@ -0,0 +1,28 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { releaseNotesFileSchema } from '../shared/release-notes-contracts'
describe('packaged release notes', () => {
it('contains matching bounded Chinese and English content', async () => {
const source = JSON.parse(
await readFile(
join(process.cwd(), 'resources', 'release-notes.json'),
'utf8'
)
) as unknown
const parsed = releaseNotesFileSchema.parse(source)
expect(parsed.releases).toContainEqual(
expect.objectContaining({ version: '0.8.18' })
)
for (const release of parsed.releases) {
expect(release.notes['zh-CN'].features).toHaveLength(
release.notes['en-US'].features.length
)
expect(release.notes['zh-CN'].fixes).toHaveLength(
release.notes['en-US'].fixes.length
)
}
})
})
+13
View File
@@ -67,6 +67,7 @@ import type {
ApplicationSettingsUpdate,
VersionCheckResult
} from '../shared/application-settings-contracts'
import type { ReleaseNotesSnapshot } from '../shared/release-notes-contracts'
import type {
SpeechModelSnapshot,
SpeechTranscriptionInput,
@@ -331,6 +332,18 @@ const desktopApi: DesktopApi = {
ipcRenderer.removeListener(ipcChannels.versionCheckResult, handler)
}
},
releaseNotes: {
getPending: () =>
ipcRenderer.invoke(
ipcChannels.releaseNotesGetPending
) as Promise<ReleaseNotesSnapshot>,
acknowledge: async (version: string) => {
await ipcRenderer.invoke(
ipcChannels.releaseNotesAcknowledge,
{ version }
)
}
},
speechModels: {
getSnapshot: () =>
ipcRenderer.invoke(
+12
View File
@@ -59,4 +59,16 @@ describe('sandboxed preload', () => {
expect(source).toContain('contextFileSelectionProgress')
expect(source).toContain('ipcRenderer.removeListener(')
})
it('exposes only bounded release-note actions', () => {
const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'),
'utf8'
)
expect(source).toContain('releaseNotes: {')
expect(source).toContain('getPending:')
expect(source).toContain('acknowledge: async (version: string)')
expect(source).toContain('ipcChannels.releaseNotesGetPending')
expect(source).toContain('ipcChannels.releaseNotesAcknowledge')
})
})
+45
View File
@@ -799,6 +799,51 @@ describe('App', () => {
}
})
it('shows and acknowledges pending release notes on startup', async () => {
const acknowledge = vi.fn(async () => {})
api.releaseNotes = {
getPending: vi.fn(async () => ({
currentVersion: '0.8.18',
releases: [
{
version: '0.8.18',
releasedAt: '2026-08-11',
notes: {
'zh-CN': {
features: ['新增版本更新说明'],
fixes: ['修复重复显示']
},
'en-US': {
features: ['Added release notes'],
fixes: ['Fixed repeated display']
}
}
}
]
})),
acknowledge
}
try {
render(<App />)
expect(
await screen.findByRole('dialog', {
name: 'GoodBuddy 0.8.18 更新内容'
})
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '开始使用' }))
await waitFor(() =>
expect(acknowledge).toHaveBeenCalledWith('0.8.18')
)
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
} finally {
delete api.releaseNotes
}
})
it('does not disturb startup when updates are current or offline', async () => {
const currentResult = {
updateAvailable: false,
+35
View File
@@ -143,6 +143,8 @@ import type {
AppNotificationInput,
AppNotificationTone
} from './notifications'
import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts'
import { ReleaseNotesDialog } from './ReleaseNotesDialog'
type AppNotification = {
id: string
@@ -1340,6 +1342,9 @@ function App(): React.JSX.Element {
const voiceStartingRef = useRef(false)
const voiceDisposedRef = useRef(false)
const startupUpdateCheckStartedRef = useRef(false)
const startupReleaseNotesStartedRef = useRef(false)
const [releaseNotes, setReleaseNotes] =
useState<ReleaseNotesSnapshot>()
const [runtime, setRuntime] = useState<AgentRuntimeStatus>()
const [runtimeStatusKey, setRuntimeStatusKey] = useState('')
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
@@ -1643,6 +1648,22 @@ function App(): React.JSX.Element {
.catch(() => undefined)
}, [i18n])
useEffect(() => {
const releaseNotesApi = window.goodbuddy.releaseNotes
if (!releaseNotesApi || startupReleaseNotesStartedRef.current) {
return
}
startupReleaseNotesStartedRef.current = true
void releaseNotesApi
.getPending()
.then((snapshot) => {
if (snapshot.releases.length > 0) {
setReleaseNotes(snapshot)
}
})
.catch(() => undefined)
}, [])
useEffect(() => {
applyAppearanceTheme(resolvedAppearanceTheme)
}, [resolvedAppearanceTheme])
@@ -6544,6 +6565,20 @@ function App(): React.JSX.Element {
dispatch={notify}
notifications={notifications}
/>
{releaseNotes && (
<ReleaseNotesDialog
locale={locale}
onAcknowledge={async (version) => {
const releaseNotesApi = window.goodbuddy.releaseNotes
if (!releaseNotesApi) {
throw new Error('Release notes service is unavailable')
}
await releaseNotesApi.acknowledge(version)
}}
onClose={() => setReleaseNotes(undefined)}
snapshot={releaseNotes}
/>
)}
{imageViewerItem && (
<div
className="image-viewer-backdrop"
@@ -0,0 +1,125 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useState } from 'react'
import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts'
import { changeUiLocale } from './i18n'
import { ReleaseNotesDialog } from './ReleaseNotesDialog'
const snapshot: ReleaseNotesSnapshot = {
currentVersion: '0.8.18',
releases: [
{
version: '0.8.18',
releasedAt: '2026-08-11',
notes: {
'zh-CN': {
features: ['新增双语界面'],
fixes: ['修复开关尺寸']
},
'en-US': {
features: ['Added a bilingual interface'],
fixes: ['Fixed switch dimensions']
}
}
}
]
}
function Harness({
acknowledge,
locale = 'zh-CN'
}: {
acknowledge: (version: string) => Promise<void>
locale?: 'zh-CN' | 'en-US'
}): React.JSX.Element {
const [open, setOpen] = useState(true)
return (
<>
<div className="app-shell">
<button type="button">Background</button>
</div>
{open && (
<ReleaseNotesDialog
locale={locale}
onAcknowledge={acknowledge}
onClose={() => setOpen(false)}
snapshot={snapshot}
/>
)}
</>
)
}
afterEach(async () => {
cleanup()
await changeUiLocale('zh-CN')
})
describe('ReleaseNotesDialog', () => {
it('shows localized notes once and acknowledges before closing', async () => {
const acknowledge = vi.fn(async () => {})
const { container } = render(<Harness acknowledge={acknowledge} />)
expect(
screen.getByRole('dialog', {
name: 'GoodBuddy 0.8.18 更新内容'
})
).toBeInTheDocument()
expect(screen.getByText('新增双语界面')).toBeInTheDocument()
expect(screen.getByText('修复开关尺寸')).toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
expect(
container.querySelector<HTMLElement>('.app-shell')?.inert
).toBe(true)
fireEvent.click(screen.getByRole('button', { name: '开始使用' }))
await waitFor(() =>
expect(acknowledge).toHaveBeenCalledWith('0.8.18')
)
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
expect(
container.querySelector<HTMLElement>('.app-shell')?.inert
).toBe(false)
})
it('keeps the dialog open when acknowledgement fails', async () => {
const acknowledge = vi.fn(async () => {
throw new Error('disk failed')
})
render(<Harness acknowledge={acknowledge} />)
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
expect(
await screen.findByRole('alert')
).toHaveTextContent('无法保存已读状态,请重试。')
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('renders the approved English release notes', async () => {
await changeUiLocale('en-US')
render(<Harness acknowledge={vi.fn(async () => {})} locale="en-US" />)
expect(
screen.getByRole('dialog', {
name: "What's New in GoodBuddy 0.8.18"
})
).toBeInTheDocument()
expect(
screen.getByText('Added a bilingual interface')
).toBeInTheDocument()
expect(screen.getByText('Fixed switch dimensions')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Get Started' })
).toBeInTheDocument()
})
})
+190
View File
@@ -0,0 +1,190 @@
import { Sparkles, Wrench, X } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import type {
ReleaseNote,
ReleaseNotesSnapshot
} from '../../shared/release-notes-contracts'
import { trapTabFocus } from './dialog-focus'
import type { UiLocale } from './i18n'
type ReleaseNotesDialogProps = {
locale: UiLocale
snapshot: ReleaseNotesSnapshot
onAcknowledge: (version: string) => Promise<void>
onClose: () => void
}
function ReleaseSection({
locale,
release,
showVersion
}: {
locale: UiLocale
release: ReleaseNote
showVersion: boolean
}): React.JSX.Element {
const { t } = useTranslation('app')
const notes = release.notes[locale]
const releaseHeadingId = useId()
const SectionHeading = showVersion ? 'h4' : 'h3'
return (
<section
aria-labelledby={showVersion ? releaseHeadingId : undefined}
className="release-notes-dialog__release"
>
{showVersion && (
<h3
className="release-notes-dialog__version"
id={releaseHeadingId}
>
GoodBuddy {release.version}
</h3>
)}
{notes.features.length > 0 && (
<div className="release-notes-dialog__section">
<SectionHeading>
<Sparkles aria-hidden="true" size={16} />
{t('releaseNotes.features')}
</SectionHeading>
<ul>
{notes.features.map((feature) => (
<li key={feature}>{feature}</li>
))}
</ul>
</div>
)}
{notes.fixes.length > 0 && (
<div className="release-notes-dialog__section">
<SectionHeading>
<Wrench aria-hidden="true" size={16} />
{t('releaseNotes.fixes')}
</SectionHeading>
<ul>
{notes.fixes.map((fix) => (
<li key={fix}>{fix}</li>
))}
</ul>
</div>
)}
</section>
)
}
export function ReleaseNotesDialog({
locale,
snapshot,
onAcknowledge,
onClose
}: ReleaseNotesDialogProps): React.JSX.Element {
const { t } = useTranslation('app')
const dialogRef = useRef<HTMLElement>(null)
const restoreFocusRef = useRef<HTMLElement | null>(null)
const [closing, setClosing] = useState(false)
const [error, setError] = useState<string>()
const titleId = useId()
const descriptionId = useId()
useEffect(() => {
restoreFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null
const appShell = document.querySelector<HTMLElement>('.app-shell')
const wasInert = appShell?.inert ?? false
if (appShell) {
appShell.inert = true
}
return () => {
if (appShell) {
appShell.inert = wasInert
}
restoreFocusRef.current?.focus()
}
}, [])
const close = async (): Promise<void> => {
if (closing) {
return
}
setClosing(true)
setError(undefined)
try {
await onAcknowledge(snapshot.currentVersion)
onClose()
} catch {
setError(t('releaseNotes.acknowledgeFailed'))
setClosing(false)
}
}
return createPortal(
<div className="release-notes-backdrop">
<section
aria-describedby={descriptionId}
aria-labelledby={titleId}
aria-modal="true"
className="release-notes-dialog"
onKeyDown={(event) => {
if (event.key === 'Escape' && !closing) {
event.preventDefault()
void close()
return
}
trapTabFocus(event, dialogRef.current)
}}
ref={dialogRef}
role="dialog"
>
<header className="release-notes-dialog__header">
<div>
<span className="release-notes-dialog__eyebrow">
{t('releaseNotes.eyebrow')}
</span>
<h2 id={titleId}>
{t('releaseNotes.title', {
version: snapshot.currentVersion
})}
</h2>
<p id={descriptionId}>{t('releaseNotes.description')}</p>
</div>
<button
aria-label={t('releaseNotes.close')}
className="icon-button"
disabled={closing}
onClick={() => void close()}
type="button"
>
<X aria-hidden="true" size={16} />
</button>
</header>
<div className="release-notes-dialog__content">
{snapshot.releases.map((release) => (
<ReleaseSection
key={release.version}
locale={locale}
release={release}
showVersion={snapshot.releases.length > 1}
/>
))}
</div>
<footer className="release-notes-dialog__footer">
{error && <p role="alert">{error}</p>}
<button
autoFocus
className="primary-button"
disabled={closing}
onClick={() => void close()}
type="button"
>
{closing
? t('releaseNotes.closing')
: t('releaseNotes.start')}
</button>
</footer>
</section>
</div>,
document.body
)
}
@@ -9,6 +9,18 @@ export const app = {
close: 'Close notification',
viewport: 'App notifications'
},
releaseNotes: {
eyebrow: 'VERSION UPDATE',
title: "What's New in GoodBuddy {{version}}",
description:
'This release includes the following features and bug fixes.',
features: 'Features',
fixes: 'Bug Fixes',
close: 'Close release notes',
start: 'Get Started',
closing: 'Closing…',
acknowledgeFailed: 'Could not save the read state. Please try again.'
},
window: {
minimizeAria: 'Minimize window',
minimize: 'Minimize',
@@ -6,6 +6,17 @@ export const app = {
close: '关闭通知',
viewport: '应用通知'
},
releaseNotes: {
eyebrow: '版本更新',
title: 'GoodBuddy {{version}} 更新内容',
description: '本次版本带来了以下功能更新与问题修复。',
features: '功能更新',
fixes: '问题修复',
close: '关闭版本更新说明',
start: '开始使用',
closing: '正在关闭…',
acknowledgeFailed: '无法保存已读状态,请重试。'
},
window: {
minimizeAria: '最小化窗口',
minimize: '最小化',
+158
View File
@@ -3585,6 +3585,141 @@ button > svg * {
object-fit: contain;
}
.release-notes-backdrop {
position: fixed;
z-index: 90;
display: grid;
padding: var(--space-4);
background: var(--overlay-backdrop);
inset: 38px 0 0;
place-items: center;
}
.release-notes-dialog {
display: grid;
width: min(680px, 100%);
max-height: calc(100vh - 70px);
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) auto;
}
.release-notes-dialog__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: var(--space-6);
border-bottom: 1px solid var(--border-subtle);
gap: var(--space-4);
}
.release-notes-dialog__header > div {
min-width: 0;
}
.release-notes-dialog__eyebrow {
display: block;
margin-bottom: var(--space-2);
color: var(--accent);
font-size: var(--font-caption);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.release-notes-dialog__header h2 {
margin: 0;
color: var(--text-primary);
font-size: var(--font-page-title);
line-height: 1.25;
}
.release-notes-dialog__header p {
margin: var(--space-2) 0 0;
color: var(--text-secondary);
font-size: var(--font-body);
line-height: 1.55;
}
.release-notes-dialog__content {
min-height: 0;
padding: var(--space-6);
overflow-y: auto;
}
.release-notes-dialog__release {
display: grid;
gap: var(--space-6);
}
.release-notes-dialog__release + .release-notes-dialog__release {
padding-top: var(--space-6);
margin-top: var(--space-6);
border-top: 1px solid var(--border-default);
}
.release-notes-dialog__version {
margin: 0;
color: var(--text-primary);
font-size: var(--font-section-title);
}
.release-notes-dialog__section {
display: grid;
gap: var(--space-3);
}
.release-notes-dialog__section :is(h3, h4) {
display: flex;
align-items: center;
margin: 0;
color: var(--text-primary);
font-size: var(--font-section-title);
gap: var(--space-2);
}
.release-notes-dialog__section :is(h3, h4) svg {
color: var(--accent);
flex: 0 0 auto;
}
.release-notes-dialog__section ul {
display: grid;
padding-left: var(--space-6);
margin: 0;
color: var(--text-secondary);
font-size: var(--font-body);
gap: var(--space-2);
line-height: 1.6;
}
.release-notes-dialog__section li::marker {
color: var(--accent);
}
.release-notes-dialog__footer {
display: flex;
align-items: center;
justify-content: flex-end;
padding: var(--space-4) var(--space-6);
border-top: 1px solid var(--border-subtle);
background: var(--surface-subtle);
gap: var(--space-4);
}
.release-notes-dialog__footer p {
margin: 0 auto 0 0;
color: var(--danger);
font-size: var(--font-caption);
}
.release-notes-dialog__footer .primary-button {
min-width: 104px;
}
.composer:focus-within {
border-color: var(--accent);
box-shadow:
@@ -9189,6 +9324,29 @@ details.settings-section > :not(summary) + :not(summary) {
}
@media (max-width: 720px) {
.release-notes-backdrop {
padding: var(--space-4);
}
.release-notes-dialog {
max-height: calc(100vh - 70px);
}
.release-notes-dialog__header,
.release-notes-dialog__content {
padding: var(--space-4);
}
.release-notes-dialog__footer {
align-items: stretch;
padding: var(--space-4);
flex-direction: column;
}
.release-notes-dialog__footer .primary-button {
width: 100%;
}
.workspace-panel-scroll {
padding: var(--page-gutter);
}
+5
View File
@@ -65,6 +65,7 @@ import type {
ApplicationSettingsUpdate,
VersionCheckResult
} from './application-settings-contracts'
import type { ReleaseNotesSnapshot } from './release-notes-contracts'
import type {
SpeechModelSnapshot,
SpeechTranscriptionInput,
@@ -1049,6 +1050,10 @@ export type DesktopApi = {
listener: (result: VersionCheckResult) => void
) => () => void
}
releaseNotes?: {
getPending: () => Promise<ReleaseNotesSnapshot>
acknowledge: (version: string) => Promise<void>
}
speechModels?: {
getSnapshot: () => Promise<SpeechModelSnapshot>
install: (modelId: string) => Promise<SpeechModelSnapshot>
+2
View File
@@ -43,6 +43,8 @@ export const ipcChannels = {
versionCheck: 'application:update:check',
versionOpenReleasePage: 'application:update:open-release-page',
versionCheckResult: 'application:update:result',
releaseNotesGetPending: 'application:release-notes:get-pending',
releaseNotesAcknowledge: 'application:release-notes:acknowledge',
speechModelsGet: 'settings:speech-models:get',
speechModelsInstall: 'settings:speech-models:install',
speechModelsCancel: 'settings:speech-models:cancel',
+77
View File
@@ -0,0 +1,77 @@
import { z } from 'zod'
export const releaseVersionSchema = z
.string()
.regex(
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/,
'Release version must be a stable semantic version'
)
const localizedReleaseNotesSchema = z
.object({
features: z.array(z.string().trim().min(1).max(240)).max(20),
fixes: z.array(z.string().trim().min(1).max(240)).max(20)
})
.strict()
.refine(
(notes) => notes.features.length > 0 || notes.fixes.length > 0,
'Release notes must contain at least one item'
)
export const releaseNoteSchema = z
.object({
version: releaseVersionSchema,
releasedAt: z.iso.date(),
notes: z
.object({
'zh-CN': localizedReleaseNotesSchema,
'en-US': localizedReleaseNotesSchema
})
.strict()
})
.strict()
export const releaseNotesFileSchema = z
.object({
formatVersion: z.literal(1),
releases: z.array(releaseNoteSchema).min(1).max(100)
})
.strict()
.superRefine((value, context) => {
const versions = new Set<string>()
for (const [index, release] of value.releases.entries()) {
if (versions.has(release.version)) {
context.addIssue({
code: 'custom',
message: `Duplicate release version: ${release.version}`,
path: ['releases', index, 'version']
})
}
versions.add(release.version)
if (
release.notes['zh-CN'].features.length !==
release.notes['en-US'].features.length ||
release.notes['zh-CN'].fixes.length !==
release.notes['en-US'].fixes.length
) {
context.addIssue({
code: 'custom',
message: 'Localized release-note sections must have matching counts',
path: ['releases', index, 'notes']
})
}
}
})
export const releaseNotesAcknowledgeSchema = z
.object({
version: releaseVersionSchema
})
.strict()
export type ReleaseNote = z.infer<typeof releaseNoteSchema>
export type ReleaseNotesSnapshot = {
currentVersion: string
releases: ReleaseNote[]
}