From 44d30b428d380489009141e7657fb402e149bb30 Mon Sep 17 00:00:00 2001 From: lofyer Date: Tue, 11 Aug 2026 20:59:26 +0800 Subject: [PATCH] feat: add bilingual release notes --- .github/workflows/packages.yml | 13 +- AGENTS.md | 21 +- build/build-release.cjs | 2 + build/release-notes.cjs | 173 +++++++++++++++++ package.json | 5 + resources/release-notes.json | 45 +++++ src/main/application-settings-store.test.ts | 39 +++- src/main/application-settings-store.ts | 110 ++++++++--- src/main/index.ts | 11 +- src/main/ipc.ts | 26 ++- src/main/release-notes-service.test.ts | 134 +++++++++++++ src/main/release-notes-service.ts | 93 +++++++++ src/main/release-notes-source.test.ts | 28 +++ src/preload/index.ts | 13 ++ src/preload/preload-sandbox.test.ts | 12 ++ src/renderer/src/App.test.tsx | 45 +++++ src/renderer/src/App.tsx | 35 ++++ src/renderer/src/ReleaseNotesDialog.test.tsx | 125 ++++++++++++ src/renderer/src/ReleaseNotesDialog.tsx | 190 +++++++++++++++++++ src/renderer/src/i18n/locales/en-US/app.ts | 12 ++ src/renderer/src/i18n/locales/zh-CN/app.ts | 11 ++ src/renderer/src/styles.css | 158 +++++++++++++++ src/shared/contracts.ts | 5 + src/shared/ipc-channels.ts | 2 + src/shared/release-notes-contracts.ts | 77 ++++++++ tests/build-release.test.ts | 1 + 26 files changed, 1344 insertions(+), 42 deletions(-) create mode 100644 build/release-notes.cjs create mode 100644 resources/release-notes.json create mode 100644 src/main/release-notes-service.test.ts create mode 100644 src/main/release-notes-service.ts create mode 100644 src/main/release-notes-source.test.ts create mode 100644 src/renderer/src/ReleaseNotesDialog.test.tsx create mode 100644 src/renderer/src/ReleaseNotesDialog.tsx create mode 100644 src/shared/release-notes-contracts.ts diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 9196ed4..b210619 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -33,6 +33,10 @@ jobs: if: github.ref_type == 'tag' run: node -e "const p=require('./package.json'); const expected='v'+p.version; if(process.env.GITHUB_REF_NAME!==expected){throw new Error('Expected tag '+expected+', received '+process.env.GITHUB_REF_NAME)}" + - name: Verify bilingual release notes + if: github.ref_type == 'tag' + run: npm run release:notes:verify + - name: Install dependencies run: npm ci @@ -153,6 +157,9 @@ jobs: test "$GITHUB_REF_NAME" = "$expected" test "$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" = "$GITHUB_SHA" + - name: Prepare bilingual release notes + run: node build/release-notes.cjs --output release-notes.md + - name: Download Windows packages uses: actions/download-artifact@v8 with: @@ -181,11 +188,11 @@ jobs: run: | set -euo pipefail tag="$GITHUB_REF_NAME" + version="$(node -p "require('./package.json').version")" if gh release view "$tag" >/dev/null 2>&1; then - gh release edit "$tag" --draft + gh release edit "$tag" --draft --title "GoodBuddy $version" --notes-file release-notes.md else - version="$(node -p "require('./package.json').version")" - gh release create "$tag" --draft --verify-tag --generate-notes --title "GoodBuddy $version" + gh release create "$tag" --draft --verify-tag --title "GoodBuddy $version" --notes-file release-notes.md fi gh release upload "$tag" dist/release-upload/* --clobber gh release edit "$tag" --draft=false --latest diff --git a/AGENTS.md b/AGENTS.md index ddb458c..3094c4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,20 +122,23 @@ not require release notes. inspect the complete commit and file diff from that tag to the release commit. For the first tagged release, inspect the relevant repository history instead. -3. Draft concise, user-facing Simplified Chinese release notes based only on - verified changes in that range. Use the title - `GoodBuddy 更新内容` and separate `功能更新` and `问题修复` - sections when applicable. Do not expose internal-only details, credentials, - private content, or unverified claims. -4. Show the exact release-note draft to the user and wait for explicit - approval. If the release commit or draft changes after approval, inspect - the updated tag range and request approval again. +3. Draft concise, user-facing release notes in both Simplified Chinese and + English based only on verified changes in that range. Use the titles + `GoodBuddy 更新内容` and + `What's New in GoodBuddy `, with corresponding `功能更新` / + `Features` and `问题修复` / `Bug Fixes` sections when applicable. The two + language versions must describe the same changes. Do not expose + internal-only details, credentials, private content, or unverified claims. +4. Show the exact bilingual release-note draft to the user and wait for + explicit approval. If the release commit or either language version changes + after approval, inspect the updated tag range and request approval again. 5. Only after approval, verify that `package.json` and `package-lock.json` contain the same release version, verify the candidate tag does not already point elsewhere, create `v${package.version}` at the exact approved commit, and push the branch and tag according to the synchronized-remote rules. -6. Keep the approved release notes as the single source for both the GitHub +6. Keep both approved language versions as the single source for the GitHub Release body and the packaged first-open release-notes modal. The modal + displays the release notes matching the current interface language and contains no button linking to a full release page. Never create or push a release tag, and never push a previously created diff --git a/build/build-release.cjs b/build/build-release.cjs index 7d2ddb4..a89a47e 100644 --- a/build/build-release.cjs +++ b/build/build-release.cjs @@ -38,6 +38,7 @@ const portableMarkerName = '.goodbuddy-portable.json' const portableRequiredFiles = [ `${productName}.exe`, 'resources/app.asar', + 'resources/release-notes.json', 'resources/icon.ico', 'resources/tray-icon.png', 'resources/runtimes/opencode/opencode.exe', @@ -380,6 +381,7 @@ function verifyUnpackedOutput(directory, options) { ) assertFile(applicationExecutable, '应用主程序') assertFile(join(resources, 'app.asar'), '应用 ASAR') + assertFile(join(resources, 'release-notes.json'), '版本更新说明') assertFile(runtimeExecutable, 'OpenCode Runtime') assertFile( join(resources, 'runtimes', 'continue', 'dist', 'index.js'), diff --git a/build/release-notes.cjs b/build/release-notes.cjs new file mode 100644 index 0000000..a9076e0 --- /dev/null +++ b/build/release-notes.cjs @@ -0,0 +1,173 @@ +const { readFileSync, writeFileSync } = require('node:fs') +const { join, resolve } = require('node:path') + +const root = resolve(__dirname, '..') +const packageJson = JSON.parse( + readFileSync(join(root, 'package.json'), 'utf8') +) +const releaseNotesFile = JSON.parse( + readFileSync(join(root, 'resources', 'release-notes.json'), 'utf8') +) + +function fail(message) { + throw new Error(`Release notes validation failed: ${message}`) +} + +function hasExactKeys(value, keys) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === keys.length && + keys.every((key) => Object.hasOwn(value, key)) + ) +} + +function validateItems(value, label) { + if (!Array.isArray(value) || value.length > 20) { + fail(`${label} must contain no more than 20 items`) + } + return value.map((item) => { + if (typeof item !== 'string') { + fail(`${label} contains a non-string item`) + } + const normalized = item.trim() + if (!normalized || normalized.length > 240) { + fail(`${label} contains an empty or oversized item`) + } + return normalized + }) +} + +function validateRelease(value, index) { + const label = `releases[${index}]` + if (!hasExactKeys(value, ['version', 'releasedAt', 'notes'])) { + fail(`${label} has invalid fields`) + } + if (!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u.test( + value.version + )) { + fail(`${label}.version must be a stable semantic version`) + } + const date = new Date(`${value.releasedAt}T00:00:00.000Z`) + if ( + !/^\d{4}-\d{2}-\d{2}$/u.test(value.releasedAt) || + Number.isNaN(date.getTime()) || + date.toISOString().slice(0, 10) !== value.releasedAt + ) { + fail(`${label}.releasedAt must be a real YYYY-MM-DD date`) + } + if (!hasExactKeys(value.notes, ['zh-CN', 'en-US'])) { + fail(`${label}.notes must contain zh-CN and en-US`) + } + const notes = Object.fromEntries( + ['zh-CN', 'en-US'].map((locale) => { + const localized = value.notes[locale] + if (!hasExactKeys(localized, ['features', 'fixes'])) { + fail(`${label}.notes.${locale} has invalid fields`) + } + const features = validateItems( + localized.features, + `${label}.notes.${locale}.features` + ) + const fixes = validateItems( + localized.fixes, + `${label}.notes.${locale}.fixes` + ) + if (features.length + fixes.length === 0) { + fail(`${label}.notes.${locale} must not be empty`) + } + return [locale, { features, fixes }] + }) + ) + if ( + notes['zh-CN'].features.length !== notes['en-US'].features.length || + notes['zh-CN'].fixes.length !== notes['en-US'].fixes.length + ) { + fail(`${label} localized section counts do not match`) + } + return { + version: value.version, + releasedAt: value.releasedAt, + notes + } +} + +if ( + !hasExactKeys(releaseNotesFile, ['formatVersion', 'releases']) || + releaseNotesFile.formatVersion !== 1 || + !Array.isArray(releaseNotesFile.releases) || + releaseNotesFile.releases.length < 1 || + releaseNotesFile.releases.length > 100 +) { + fail('unsupported file format') +} + +const allReleases = releaseNotesFile.releases.map(validateRelease) +const uniqueVersionCount = new Set( + allReleases.map((release) => release.version) +).size +if (uniqueVersionCount !== allReleases.length) { + fail('release versions must be unique') +} + +const releases = allReleases.filter( + (release) => release?.version === packageJson.version +) +if (releases.length !== 1) { + fail( + `expected exactly one entry for package version ${packageJson.version}` + ) +} + +const release = releases[0] + +const localizedDefinitions = [ + { + locale: 'zh-CN', + title: `GoodBuddy ${release.version} 更新内容`, + features: '功能更新', + fixes: '问题修复' + }, + { + locale: 'en-US', + title: `What's New in GoodBuddy ${release.version}`, + features: 'Features', + fixes: 'Bug Fixes' + } +] + +function markdownSection(title, items) { + if (items.length === 0) { + return [] + } + return [`## ${title}`, '', ...items.map((item) => `- ${item}`), ''] +} + +const markdown = localizedDefinitions + .flatMap((definition, index) => { + const notes = release.notes[definition.locale] + return [ + ...(index === 0 ? [] : ['---', '']), + `# ${definition.title}`, + '', + ...markdownSection(definition.features, notes.features), + ...markdownSection(definition.fixes, notes.fixes) + ] + }) + .join('\n') + .trimEnd() + .concat('\n') + +const outputIndex = process.argv.indexOf('--output') +if (outputIndex >= 0) { + const outputPath = process.argv[outputIndex + 1] + if (!outputPath) { + fail('--output requires a path') + } + writeFileSync(resolve(root, outputPath), markdown, 'utf8') +} else { + process.stdout.write( + `Validated bilingual release notes for ${packageJson.version}\n` + ) +} diff --git a/package.json b/package.json index ad72082..da7977b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:watch": "vitest", "build": "npm run typecheck && npm run build:bundle", "build:bundle": "electron-vite build", + "release:notes:verify": "node build/release-notes.cjs", "dist": "npm run build && electron-builder", "dist:win": "npm run build && electron-builder --win nsis --x64 --arm64", "dist:mac": "npm run build && electron-builder --mac dmg --x64 --arm64", @@ -53,6 +54,10 @@ "**/*" ] }, + { + "from": "resources/release-notes.json", + "to": "release-notes.json" + }, { "from": "build/icon-taskbar.ico", "to": "icon.ico" diff --git a/resources/release-notes.json b/resources/release-notes.json new file mode 100644 index 0000000..d6a8620 --- /dev/null +++ b/resources/release-notes.json @@ -0,0 +1,45 @@ +{ + "formatVersion": 1, + "releases": [ + { + "version": "0.8.18", + "releasedAt": "2026-08-11", + "notes": { + "zh-CN": { + "features": [ + "新增简体中文与英文界面,可在设置中即时切换并跟随系统语言。", + "新增统一的文档解析中心,为聊天附件和知识库导入提供原生文本提取、PDF 页面处理与真实文件诊断。", + "新增本地 PP-OCRv6 Tiny、Small 和 Medium 模型,支持校验下载、离线识别以及受管 ZIP 导入和导出。", + "扩展离线语音模型,新增中英与中粤英 Paraformer,以及 Whisper Small 和 Medium 多语言档位。", + "增强魔法笔记,支持富文本、图片、视频、附件、待办状态和可配置的 AI 评论方式。", + "支持为每个项目设置新对话的默认 Runtime。", + "扩展直连模型工具与文档处理能力,增加联网搜索、网页读取、附件解析进度和当前系统时间上下文。", + "新增首次启动版本更新说明,按当前界面语言展示且每个版本仅自动显示一次。" + ], + "fixes": [ + "修复 Execute 模式下内置 OpenCode 和 Continue 仍可能阻止已授权工具的问题。", + "修复工具失败信息重复显示,并仅为最近一次失败保留重新编辑入口。", + "修复共享开关在部分设置布局中尺寸被文本输入样式覆盖的问题。" + ] + }, + "en-US": { + "features": [ + "Added Simplified Chinese and English interfaces with instant switching in Settings and system-language support.", + "Added a unified document parsing center for chat attachments and knowledge imports, with native text extraction, PDF page handling, and real-file diagnostics.", + "Added local PP-OCRv6 Tiny, Small, and Medium models with verified downloads, offline recognition, and managed ZIP import and export.", + "Expanded offline speech models with bilingual and Mandarin-Cantonese-English Paraformer options, plus Whisper Small and Medium multilingual tiers.", + "Enhanced Magic Notes with rich text, images, videos, attachments, editable todo states, and configurable AI comment modes.", + "Added a per-project default Runtime for new conversations.", + "Expanded direct-model tools and document handling with web search, webpage reading, attachment parsing progress, and current system-time context.", + "Added first-open release notes that follow the current interface language and appear automatically only once per version." + ], + "fixes": [ + "Fixed authorized tools still being blocked for bundled OpenCode and Continue in Execute mode.", + "Fixed duplicate tool-failure messages and limited the edit-and-retry action to the latest failed response.", + "Fixed shared switches inheriting text-input dimensions in some settings layouts." + ] + } + } + } + ] +} diff --git a/src/main/application-settings-store.test.ts b/src/main/application-settings-store.test.ts index 5226f53..b60ed72 100644 --- a/src/main/application-settings-store.test.ts +++ b/src/main/application-settings-store.test.ts @@ -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 }) }) diff --git a/src/main/application-settings-store.ts b/src/main/application-settings-store.ts index 554cd28..992be5a 100644 --- a/src/main/application-settings-store.ts +++ b/src/main/application-settings-store.ts @@ -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 private updateQueue: Promise = 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 { 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 { + return (await this.loadStored()).lastSeenReleaseNotesVersion + } + + private async persist(next: StoredApplicationSettings): Promise { + 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 { 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 { + 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 + } } diff --git a/src/main/index.ts b/src/main/index.ts index b0e5cc8..5407d68 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index affcbb2..35e0887 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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 { const activeRequests = new Map() 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 => { const settings = await settingsStore.getResolvedSettings() if (!settings.knowledgeEmbeddingEnabled) { diff --git a/src/main/release-notes-service.test.ts b/src/main/release-notes-service.test.ts new file mode 100644 index 0000000..aef27f9 --- /dev/null +++ b/src/main/release-notes-service.test.ts @@ -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' + ) + }) +}) diff --git a/src/main/release-notes-service.ts b/src/main/release-notes-service.ts new file mode 100644 index 0000000..a48abc5 --- /dev/null +++ b/src/main/release-notes-service.ts @@ -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 + + constructor( + private readonly dependencies: { + currentVersion: string + filePath: string + settingsStore: ApplicationSettingsStore + } + ) {} + + private async loadReleases(): Promise { + if (this.releases) { + return this.releases + } + if (!this.releaseLoad) { + this.releaseLoad = this.readReleases().finally(() => { + this.releaseLoad = undefined + }) + } + return this.releaseLoad + } + + private async readReleases(): Promise { + 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 { + 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 { + 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 + ) + } +} diff --git a/src/main/release-notes-source.test.ts b/src/main/release-notes-source.test.ts new file mode 100644 index 0000000..225db70 --- /dev/null +++ b/src/main/release-notes-source.test.ts @@ -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 + ) + } + }) +}) diff --git a/src/preload/index.ts b/src/preload/index.ts index eeed65a..b2f1db0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, + acknowledge: async (version: string) => { + await ipcRenderer.invoke( + ipcChannels.releaseNotesAcknowledge, + { version } + ) + } + }, speechModels: { getSnapshot: () => ipcRenderer.invoke( diff --git a/src/preload/preload-sandbox.test.ts b/src/preload/preload-sandbox.test.ts index b44c0f0..acb20d6 100644 --- a/src/preload/preload-sandbox.test.ts +++ b/src/preload/preload-sandbox.test.ts @@ -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') + }) }) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 13e539a..44a7380 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -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() + + 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, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2831010..39c5c52 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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() const [runtime, setRuntime] = useState() const [runtimeStatusKey, setRuntimeStatusKey] = useState('') const [runtimeSettings, setRuntimeSettings] = useState() @@ -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 && ( + { + 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 && (
Promise + locale?: 'zh-CN' | 'en-US' +}): React.JSX.Element { + const [open, setOpen] = useState(true) + return ( + <> +
+ +
+ {open && ( + 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() + + 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('.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('.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() + + 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( {})} 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() + }) +}) diff --git a/src/renderer/src/ReleaseNotesDialog.tsx b/src/renderer/src/ReleaseNotesDialog.tsx new file mode 100644 index 0000000..ae4c091 --- /dev/null +++ b/src/renderer/src/ReleaseNotesDialog.tsx @@ -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 + 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 ( +
+ {showVersion && ( +

+ GoodBuddy {release.version} +

+ )} + {notes.features.length > 0 && ( +
+ + +
    + {notes.features.map((feature) => ( +
  • {feature}
  • + ))} +
+
+ )} + {notes.fixes.length > 0 && ( +
+ + +
    + {notes.fixes.map((fix) => ( +
  • {fix}
  • + ))} +
+
+ )} +
+ ) +} + +export function ReleaseNotesDialog({ + locale, + snapshot, + onAcknowledge, + onClose +}: ReleaseNotesDialogProps): React.JSX.Element { + const { t } = useTranslation('app') + const dialogRef = useRef(null) + const restoreFocusRef = useRef(null) + const [closing, setClosing] = useState(false) + const [error, setError] = useState() + const titleId = useId() + const descriptionId = useId() + + useEffect(() => { + restoreFocusRef.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null + const appShell = document.querySelector('.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 => { + if (closing) { + return + } + setClosing(true) + setError(undefined) + try { + await onAcknowledge(snapshot.currentVersion) + onClose() + } catch { + setError(t('releaseNotes.acknowledgeFailed')) + setClosing(false) + } + } + + return createPortal( +
+
{ + if (event.key === 'Escape' && !closing) { + event.preventDefault() + void close() + return + } + trapTabFocus(event, dialogRef.current) + }} + ref={dialogRef} + role="dialog" + > +
+
+ + {t('releaseNotes.eyebrow')} + +

+ {t('releaseNotes.title', { + version: snapshot.currentVersion + })} +

+

{t('releaseNotes.description')}

+
+ +
+
+ {snapshot.releases.map((release) => ( + 1} + /> + ))} +
+
+ {error &&

{error}

} + +
+
+
, + document.body + ) +} diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index 6189ca5..f7da438 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -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', diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index b588df2..ac9c20a 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -6,6 +6,17 @@ export const app = { close: '关闭通知', viewport: '应用通知' }, + releaseNotes: { + eyebrow: '版本更新', + title: 'GoodBuddy {{version}} 更新内容', + description: '本次版本带来了以下功能更新与问题修复。', + features: '功能更新', + fixes: '问题修复', + close: '关闭版本更新说明', + start: '开始使用', + closing: '正在关闭…', + acknowledgeFailed: '无法保存已读状态,请重试。' + }, window: { minimizeAria: '最小化窗口', minimize: '最小化', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 6f6a2a5..ed38cd8 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -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); } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index f75af91..ed858dd 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -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 + acknowledge: (version: string) => Promise + } speechModels?: { getSnapshot: () => Promise install: (modelId: string) => Promise diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index b3c2226..e267ba9 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -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', diff --git a/src/shared/release-notes-contracts.ts b/src/shared/release-notes-contracts.ts new file mode 100644 index 0000000..73612d2 --- /dev/null +++ b/src/shared/release-notes-contracts.ts @@ -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() + 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 + +export type ReleaseNotesSnapshot = { + currentVersion: string + releases: ReleaseNote[] +} diff --git a/tests/build-release.test.ts b/tests/build-release.test.ts index 18ff98d..dc08cdd 100644 --- a/tests/build-release.test.ts +++ b/tests/build-release.test.ts @@ -123,6 +123,7 @@ function portableDirectory(parent: string): string { for (const [path, content] of [ ['GoodBuddy.exe', 'MZ'], ['resources/app.asar', 'asar'], + ['resources/release-notes.json', '{}'], ['resources/icon.ico', 'icon'], ['resources/tray-icon.png', 'tray'], ['resources/runtimes/opencode/opencode.exe', 'MZ'],