feat: add bilingual release notes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <version> 更新内容` 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 <version> 更新内容` and
|
||||
`What's New in GoodBuddy <version>`, 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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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: '最小化',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user