feat: add bilingual release notes
This commit is contained in:
@@ -33,6 +33,10 @@ jobs:
|
|||||||
if: github.ref_type == 'tag'
|
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)}"
|
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
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
@@ -153,6 +157,9 @@ jobs:
|
|||||||
test "$GITHUB_REF_NAME" = "$expected"
|
test "$GITHUB_REF_NAME" = "$expected"
|
||||||
test "$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" = "$GITHUB_SHA"
|
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
|
- name: Download Windows packages
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
@@ -181,11 +188,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
tag="$GITHUB_REF_NAME"
|
tag="$GITHUB_REF_NAME"
|
||||||
|
version="$(node -p "require('./package.json').version")"
|
||||||
if gh release view "$tag" >/dev/null 2>&1; then
|
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
|
else
|
||||||
version="$(node -p "require('./package.json').version")"
|
gh release create "$tag" --draft --verify-tag --title "GoodBuddy $version" --notes-file release-notes.md
|
||||||
gh release create "$tag" --draft --verify-tag --generate-notes --title "GoodBuddy $version"
|
|
||||||
fi
|
fi
|
||||||
gh release upload "$tag" dist/release-upload/* --clobber
|
gh release upload "$tag" dist/release-upload/* --clobber
|
||||||
gh release edit "$tag" --draft=false --latest
|
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
|
inspect the complete commit and file diff from that tag to the release
|
||||||
commit. For the first tagged release, inspect the relevant repository
|
commit. For the first tagged release, inspect the relevant repository
|
||||||
history instead.
|
history instead.
|
||||||
3. Draft concise, user-facing Simplified Chinese release notes based only on
|
3. Draft concise, user-facing release notes in both Simplified Chinese and
|
||||||
verified changes in that range. Use the title
|
English based only on verified changes in that range. Use the titles
|
||||||
`GoodBuddy <version> 更新内容` and separate `功能更新` and `问题修复`
|
`GoodBuddy <version> 更新内容` and
|
||||||
sections when applicable. Do not expose internal-only details, credentials,
|
`What's New in GoodBuddy <version>`, with corresponding `功能更新` /
|
||||||
private content, or unverified claims.
|
`Features` and `问题修复` / `Bug Fixes` sections when applicable. The two
|
||||||
4. Show the exact release-note draft to the user and wait for explicit
|
language versions must describe the same changes. Do not expose
|
||||||
approval. If the release commit or draft changes after approval, inspect
|
internal-only details, credentials, private content, or unverified claims.
|
||||||
the updated tag range and request approval again.
|
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`
|
5. Only after approval, verify that `package.json` and `package-lock.json`
|
||||||
contain the same release version, verify the candidate tag does not already
|
contain the same release version, verify the candidate tag does not already
|
||||||
point elsewhere, create `v${package.version}` at the exact approved commit,
|
point elsewhere, create `v${package.version}` at the exact approved commit,
|
||||||
and push the branch and tag according to the synchronized-remote rules.
|
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
|
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.
|
contains no button linking to a full release page.
|
||||||
|
|
||||||
Never create or push a release tag, and never push a previously created
|
Never create or push a release tag, and never push a previously created
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const portableMarkerName = '.goodbuddy-portable.json'
|
|||||||
const portableRequiredFiles = [
|
const portableRequiredFiles = [
|
||||||
`${productName}.exe`,
|
`${productName}.exe`,
|
||||||
'resources/app.asar',
|
'resources/app.asar',
|
||||||
|
'resources/release-notes.json',
|
||||||
'resources/icon.ico',
|
'resources/icon.ico',
|
||||||
'resources/tray-icon.png',
|
'resources/tray-icon.png',
|
||||||
'resources/runtimes/opencode/opencode.exe',
|
'resources/runtimes/opencode/opencode.exe',
|
||||||
@@ -380,6 +381,7 @@ function verifyUnpackedOutput(directory, options) {
|
|||||||
)
|
)
|
||||||
assertFile(applicationExecutable, '应用主程序')
|
assertFile(applicationExecutable, '应用主程序')
|
||||||
assertFile(join(resources, 'app.asar'), '应用 ASAR')
|
assertFile(join(resources, 'app.asar'), '应用 ASAR')
|
||||||
|
assertFile(join(resources, 'release-notes.json'), '版本更新说明')
|
||||||
assertFile(runtimeExecutable, 'OpenCode Runtime')
|
assertFile(runtimeExecutable, 'OpenCode Runtime')
|
||||||
assertFile(
|
assertFile(
|
||||||
join(resources, 'runtimes', 'continue', 'dist', 'index.js'),
|
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",
|
"test:watch": "vitest",
|
||||||
"build": "npm run typecheck && npm run build:bundle",
|
"build": "npm run typecheck && npm run build:bundle",
|
||||||
"build:bundle": "electron-vite build",
|
"build:bundle": "electron-vite build",
|
||||||
|
"release:notes:verify": "node build/release-notes.cjs",
|
||||||
"dist": "npm run build && electron-builder",
|
"dist": "npm run build && electron-builder",
|
||||||
"dist:win": "npm run build && electron-builder --win nsis --x64 --arm64",
|
"dist:win": "npm run build && electron-builder --win nsis --x64 --arm64",
|
||||||
"dist:mac": "npm run build && electron-builder --mac dmg --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",
|
"from": "build/icon-taskbar.ico",
|
||||||
"to": "icon.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'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||||
version: 4,
|
version: 5,
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined',
|
||||||
|
lastSeenReleaseNotesVersion: null
|
||||||
})
|
})
|
||||||
expect(
|
expect(
|
||||||
(await readdir(directory)).filter((name) => name.endsWith('.tmp'))
|
(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', () => {
|
it('strictly rejects incomplete full settings', () => {
|
||||||
for (const input of [
|
for (const input of [
|
||||||
{},
|
{},
|
||||||
@@ -290,11 +320,12 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||||
version: 4,
|
version: 5,
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined',
|
||||||
|
lastSeenReleaseNotesVersion: null
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ import {
|
|||||||
applicationSettingsUpdateSchema,
|
applicationSettingsUpdateSchema,
|
||||||
type ApplicationSettings
|
type ApplicationSettings
|
||||||
} from '../shared/application-settings-contracts'
|
} from '../shared/application-settings-contracts'
|
||||||
|
import { releaseVersionSchema } from '../shared/release-notes-contracts'
|
||||||
export {
|
export {
|
||||||
applicationSettingsSchema,
|
applicationSettingsSchema,
|
||||||
applicationSettingsUpdateSchema
|
applicationSettingsUpdateSchema
|
||||||
} from '../shared/application-settings-contracts'
|
} from '../shared/application-settings-contracts'
|
||||||
export type { ApplicationSettings } 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
|
const legacyStoredApplicationSettingsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -45,9 +46,16 @@ const versionThreeStoredApplicationSettingsSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
|
const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
|
||||||
|
.extend({
|
||||||
|
version: z.literal(4)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
const storedApplicationSettingsSchema = applicationSettingsSchema
|
const storedApplicationSettingsSchema = applicationSettingsSchema
|
||||||
.extend({
|
.extend({
|
||||||
version: z.literal(CURRENT_SETTINGS_VERSION)
|
version: z.literal(CURRENT_SETTINGS_VERSION),
|
||||||
|
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
@@ -73,6 +81,7 @@ function isMissingFile(error: unknown): boolean {
|
|||||||
|
|
||||||
export class ApplicationSettingsStore {
|
export class ApplicationSettingsStore {
|
||||||
private settings?: StoredApplicationSettings
|
private settings?: StoredApplicationSettings
|
||||||
|
private settingsLoad?: Promise<StoredApplicationSettings>
|
||||||
private updateQueue: Promise<void> = Promise.resolve()
|
private updateQueue: Promise<void> = Promise.resolve()
|
||||||
|
|
||||||
constructor(private readonly filePath: string) {}
|
constructor(private readonly filePath: string) {}
|
||||||
@@ -97,6 +106,15 @@ export class ApplicationSettingsStore {
|
|||||||
if (this.settings) {
|
if (this.settings) {
|
||||||
return 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 {
|
try {
|
||||||
const contents = await readFile(this.filePath, 'utf8')
|
const contents = await readFile(this.filePath, 'utf8')
|
||||||
let parsed: unknown
|
let parsed: unknown
|
||||||
@@ -106,19 +124,31 @@ export class ApplicationSettingsStore {
|
|||||||
await this.isolateCorruptFile()
|
await this.isolateCorruptFile()
|
||||||
this.settings = {
|
this.settings = {
|
||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
|
lastSeenReleaseNotesVersion: null,
|
||||||
...defaultApplicationSettings
|
...defaultApplicationSettings
|
||||||
}
|
}
|
||||||
return this.settings
|
return this.settings
|
||||||
}
|
}
|
||||||
const result = storedApplicationSettingsSchema.safeParse(parsed)
|
const result = storedApplicationSettingsSchema.safeParse(parsed)
|
||||||
if (!result.success) {
|
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 =
|
const versionThreeResult =
|
||||||
versionThreeStoredApplicationSettingsSchema.safeParse(parsed)
|
versionThreeStoredApplicationSettingsSchema.safeParse(parsed)
|
||||||
if (versionThreeResult.success) {
|
if (versionThreeResult.success) {
|
||||||
this.settings = {
|
this.settings = {
|
||||||
...versionThreeResult.data,
|
...versionThreeResult.data,
|
||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined',
|
||||||
|
lastSeenReleaseNotesVersion: null
|
||||||
}
|
}
|
||||||
return this.settings
|
return this.settings
|
||||||
}
|
}
|
||||||
@@ -129,7 +159,8 @@ export class ApplicationSettingsStore {
|
|||||||
...versionTwoResult.data,
|
...versionTwoResult.data,
|
||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined',
|
||||||
|
lastSeenReleaseNotesVersion: null
|
||||||
}
|
}
|
||||||
return this.settings
|
return this.settings
|
||||||
}
|
}
|
||||||
@@ -142,13 +173,15 @@ export class ApplicationSettingsStore {
|
|||||||
legacyResult.data.checkUpdatesOnStartup,
|
legacyResult.data.checkUpdatesOnStartup,
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined',
|
||||||
|
lastSeenReleaseNotesVersion: null
|
||||||
}
|
}
|
||||||
return this.settings
|
return this.settings
|
||||||
}
|
}
|
||||||
await this.isolateCorruptFile()
|
await this.isolateCorruptFile()
|
||||||
this.settings = {
|
this.settings = {
|
||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
|
lastSeenReleaseNotesVersion: null,
|
||||||
...defaultApplicationSettings
|
...defaultApplicationSettings
|
||||||
}
|
}
|
||||||
return this.settings
|
return this.settings
|
||||||
@@ -162,6 +195,7 @@ export class ApplicationSettingsStore {
|
|||||||
}
|
}
|
||||||
this.settings = {
|
this.settings = {
|
||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
|
lastSeenReleaseNotesVersion: null,
|
||||||
...defaultApplicationSettings
|
...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> {
|
update(input: unknown): Promise<ApplicationSettings> {
|
||||||
const operation = this.updateQueue.then(async () => {
|
const operation = this.updateQueue.then(async () => {
|
||||||
const updates = applicationSettingsUpdateSchema.parse(input)
|
const updates = applicationSettingsUpdateSchema.parse(input)
|
||||||
@@ -187,25 +247,7 @@ export class ApplicationSettingsStore {
|
|||||||
...updates,
|
...updates,
|
||||||
version: CURRENT_SETTINGS_VERSION
|
version: CURRENT_SETTINGS_VERSION
|
||||||
}
|
}
|
||||||
await mkdir(dirname(this.filePath), { recursive: true })
|
await this.persist(next)
|
||||||
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
|
|
||||||
return {
|
return {
|
||||||
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
|
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
|
||||||
magicNotesEnabled: next.magicNotesEnabled,
|
magicNotesEnabled: next.magicNotesEnabled,
|
||||||
@@ -219,4 +261,24 @@ export class ApplicationSettingsStore {
|
|||||||
)
|
)
|
||||||
return operation
|
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 { DocumentOcrModelManager } from './document-ocr-model-manager'
|
||||||
import { DocumentOcrBroker } from './document-ocr-broker'
|
import { DocumentOcrBroker } from './document-ocr-broker'
|
||||||
import { DocumentParsingService } from './document-parsing-service'
|
import { DocumentParsingService } from './document-parsing-service'
|
||||||
|
import { ReleaseNotesService } from './release-notes-service'
|
||||||
|
|
||||||
const shortcut = 'CommandOrControl+Shift+Space'
|
const shortcut = 'CommandOrControl+Shift+Space'
|
||||||
const mainModuleDirectory = dirname(fileURLToPath(import.meta.url))
|
const mainModuleDirectory = dirname(fileURLToPath(import.meta.url))
|
||||||
@@ -346,6 +347,13 @@ if (hasSingleInstanceLock) {
|
|||||||
const applicationSettingsStore = new ApplicationSettingsStore(
|
const applicationSettingsStore = new ApplicationSettingsStore(
|
||||||
join(app.getPath('userData'), 'application-settings.json')
|
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 =
|
const documentParsingSettingsStore =
|
||||||
new DocumentParsingSettingsStore(
|
new DocumentParsingSettingsStore(
|
||||||
join(app.getPath('userData'), 'document-parsing-settings.json')
|
join(app.getPath('userData'), 'document-parsing-settings.json')
|
||||||
@@ -545,7 +553,8 @@ if (hasSingleInstanceLock) {
|
|||||||
launchWechatSidecar,
|
launchWechatSidecar,
|
||||||
documentParsingService,
|
documentParsingService,
|
||||||
documentOcrModelManager,
|
documentOcrModelManager,
|
||||||
documentOcrBroker
|
documentOcrBroker,
|
||||||
|
releaseNotesService
|
||||||
)
|
)
|
||||||
loadMainWindow(mainWindow)
|
loadMainWindow(mainWindow)
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -66,6 +66,7 @@ import {
|
|||||||
weComChannelSettingsInputSchema
|
weComChannelSettingsInputSchema
|
||||||
} from '../shared/channel-settings-contracts'
|
} from '../shared/channel-settings-contracts'
|
||||||
import { applicationSettingsUpdateSchema } from '../shared/application-settings-contracts'
|
import { applicationSettingsUpdateSchema } from '../shared/application-settings-contracts'
|
||||||
|
import { releaseNotesAcknowledgeSchema } from '../shared/release-notes-contracts'
|
||||||
import {
|
import {
|
||||||
speechModelActionInputSchema,
|
speechModelActionInputSchema,
|
||||||
speechModelSelectionInputSchema
|
speechModelSelectionInputSchema
|
||||||
@@ -189,6 +190,7 @@ import type { EmbeddingIndexCoordinator } from './knowledge/embedding-index-coor
|
|||||||
import type { DocumentParsingService } from './document-parsing-service'
|
import type { DocumentParsingService } from './document-parsing-service'
|
||||||
import type { DocumentOcrModelManager } from './document-ocr-model-manager'
|
import type { DocumentOcrModelManager } from './document-ocr-model-manager'
|
||||||
import type { DocumentOcrBroker } from './document-ocr-broker'
|
import type { DocumentOcrBroker } from './document-ocr-broker'
|
||||||
|
import type { ReleaseNotesService } from './release-notes-service'
|
||||||
import { OpenAIEmbeddingClient } from './knowledge/openai-embedding-client'
|
import { OpenAIEmbeddingClient } from './knowledge/openai-embedding-client'
|
||||||
import {
|
import {
|
||||||
magicNotePlainText,
|
magicNotePlainText,
|
||||||
@@ -600,7 +602,8 @@ export function registerIpcHandlers(
|
|||||||
launchWechatSidecar?: WechatSidecarLauncher,
|
launchWechatSidecar?: WechatSidecarLauncher,
|
||||||
documentParsingService?: DocumentParsingService,
|
documentParsingService?: DocumentParsingService,
|
||||||
documentOcrModelManager?: DocumentOcrModelManager,
|
documentOcrModelManager?: DocumentOcrModelManager,
|
||||||
documentOcrBroker?: DocumentOcrBroker
|
documentOcrBroker?: DocumentOcrBroker,
|
||||||
|
releaseNotesService?: ReleaseNotesService
|
||||||
): () => Promise<void> {
|
): () => Promise<void> {
|
||||||
const activeRequests = new Map<string, AbortController>()
|
const activeRequests = new Map<string, AbortController>()
|
||||||
const pendingAgentQuestions = new Map<
|
const pendingAgentQuestions = new Map<
|
||||||
@@ -2742,6 +2745,27 @@ export function registerIpcHandlers(
|
|||||||
await shell.openExternal(GOODBUDDY_RELEASES_URL)
|
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 requireEmbeddingProvider = async (): Promise<OpenAIEmbeddingClient> => {
|
||||||
const settings = await settingsStore.getResolvedSettings()
|
const settings = await settingsStore.getResolvedSettings()
|
||||||
if (!settings.knowledgeEmbeddingEnabled) {
|
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,
|
ApplicationSettingsUpdate,
|
||||||
VersionCheckResult
|
VersionCheckResult
|
||||||
} from '../shared/application-settings-contracts'
|
} from '../shared/application-settings-contracts'
|
||||||
|
import type { ReleaseNotesSnapshot } from '../shared/release-notes-contracts'
|
||||||
import type {
|
import type {
|
||||||
SpeechModelSnapshot,
|
SpeechModelSnapshot,
|
||||||
SpeechTranscriptionInput,
|
SpeechTranscriptionInput,
|
||||||
@@ -331,6 +332,18 @@ const desktopApi: DesktopApi = {
|
|||||||
ipcRenderer.removeListener(ipcChannels.versionCheckResult, handler)
|
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: {
|
speechModels: {
|
||||||
getSnapshot: () =>
|
getSnapshot: () =>
|
||||||
ipcRenderer.invoke(
|
ipcRenderer.invoke(
|
||||||
|
|||||||
@@ -59,4 +59,16 @@ describe('sandboxed preload', () => {
|
|||||||
expect(source).toContain('contextFileSelectionProgress')
|
expect(source).toContain('contextFileSelectionProgress')
|
||||||
expect(source).toContain('ipcRenderer.removeListener(')
|
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 () => {
|
it('does not disturb startup when updates are current or offline', async () => {
|
||||||
const currentResult = {
|
const currentResult = {
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ import type {
|
|||||||
AppNotificationInput,
|
AppNotificationInput,
|
||||||
AppNotificationTone
|
AppNotificationTone
|
||||||
} from './notifications'
|
} from './notifications'
|
||||||
|
import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts'
|
||||||
|
import { ReleaseNotesDialog } from './ReleaseNotesDialog'
|
||||||
|
|
||||||
type AppNotification = {
|
type AppNotification = {
|
||||||
id: string
|
id: string
|
||||||
@@ -1340,6 +1342,9 @@ function App(): React.JSX.Element {
|
|||||||
const voiceStartingRef = useRef(false)
|
const voiceStartingRef = useRef(false)
|
||||||
const voiceDisposedRef = useRef(false)
|
const voiceDisposedRef = useRef(false)
|
||||||
const startupUpdateCheckStartedRef = useRef(false)
|
const startupUpdateCheckStartedRef = useRef(false)
|
||||||
|
const startupReleaseNotesStartedRef = useRef(false)
|
||||||
|
const [releaseNotes, setReleaseNotes] =
|
||||||
|
useState<ReleaseNotesSnapshot>()
|
||||||
const [runtime, setRuntime] = useState<AgentRuntimeStatus>()
|
const [runtime, setRuntime] = useState<AgentRuntimeStatus>()
|
||||||
const [runtimeStatusKey, setRuntimeStatusKey] = useState('')
|
const [runtimeStatusKey, setRuntimeStatusKey] = useState('')
|
||||||
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
|
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
|
||||||
@@ -1643,6 +1648,22 @@ function App(): React.JSX.Element {
|
|||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}, [i18n])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
applyAppearanceTheme(resolvedAppearanceTheme)
|
applyAppearanceTheme(resolvedAppearanceTheme)
|
||||||
}, [resolvedAppearanceTheme])
|
}, [resolvedAppearanceTheme])
|
||||||
@@ -6544,6 +6565,20 @@ function App(): React.JSX.Element {
|
|||||||
dispatch={notify}
|
dispatch={notify}
|
||||||
notifications={notifications}
|
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 && (
|
{imageViewerItem && (
|
||||||
<div
|
<div
|
||||||
className="image-viewer-backdrop"
|
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',
|
close: 'Close notification',
|
||||||
viewport: 'App notifications'
|
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: {
|
window: {
|
||||||
minimizeAria: 'Minimize window',
|
minimizeAria: 'Minimize window',
|
||||||
minimize: 'Minimize',
|
minimize: 'Minimize',
|
||||||
|
|||||||
@@ -6,6 +6,17 @@ export const app = {
|
|||||||
close: '关闭通知',
|
close: '关闭通知',
|
||||||
viewport: '应用通知'
|
viewport: '应用通知'
|
||||||
},
|
},
|
||||||
|
releaseNotes: {
|
||||||
|
eyebrow: '版本更新',
|
||||||
|
title: 'GoodBuddy {{version}} 更新内容',
|
||||||
|
description: '本次版本带来了以下功能更新与问题修复。',
|
||||||
|
features: '功能更新',
|
||||||
|
fixes: '问题修复',
|
||||||
|
close: '关闭版本更新说明',
|
||||||
|
start: '开始使用',
|
||||||
|
closing: '正在关闭…',
|
||||||
|
acknowledgeFailed: '无法保存已读状态,请重试。'
|
||||||
|
},
|
||||||
window: {
|
window: {
|
||||||
minimizeAria: '最小化窗口',
|
minimizeAria: '最小化窗口',
|
||||||
minimize: '最小化',
|
minimize: '最小化',
|
||||||
|
|||||||
@@ -3585,6 +3585,141 @@ button > svg * {
|
|||||||
object-fit: contain;
|
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 {
|
.composer:focus-within {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@@ -9189,6 +9324,29 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@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 {
|
.workspace-panel-scroll {
|
||||||
padding: var(--page-gutter);
|
padding: var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import type {
|
|||||||
ApplicationSettingsUpdate,
|
ApplicationSettingsUpdate,
|
||||||
VersionCheckResult
|
VersionCheckResult
|
||||||
} from './application-settings-contracts'
|
} from './application-settings-contracts'
|
||||||
|
import type { ReleaseNotesSnapshot } from './release-notes-contracts'
|
||||||
import type {
|
import type {
|
||||||
SpeechModelSnapshot,
|
SpeechModelSnapshot,
|
||||||
SpeechTranscriptionInput,
|
SpeechTranscriptionInput,
|
||||||
@@ -1049,6 +1050,10 @@ export type DesktopApi = {
|
|||||||
listener: (result: VersionCheckResult) => void
|
listener: (result: VersionCheckResult) => void
|
||||||
) => () => void
|
) => () => void
|
||||||
}
|
}
|
||||||
|
releaseNotes?: {
|
||||||
|
getPending: () => Promise<ReleaseNotesSnapshot>
|
||||||
|
acknowledge: (version: string) => Promise<void>
|
||||||
|
}
|
||||||
speechModels?: {
|
speechModels?: {
|
||||||
getSnapshot: () => Promise<SpeechModelSnapshot>
|
getSnapshot: () => Promise<SpeechModelSnapshot>
|
||||||
install: (modelId: string) => Promise<SpeechModelSnapshot>
|
install: (modelId: string) => Promise<SpeechModelSnapshot>
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export const ipcChannels = {
|
|||||||
versionCheck: 'application:update:check',
|
versionCheck: 'application:update:check',
|
||||||
versionOpenReleasePage: 'application:update:open-release-page',
|
versionOpenReleasePage: 'application:update:open-release-page',
|
||||||
versionCheckResult: 'application:update:result',
|
versionCheckResult: 'application:update:result',
|
||||||
|
releaseNotesGetPending: 'application:release-notes:get-pending',
|
||||||
|
releaseNotesAcknowledge: 'application:release-notes:acknowledge',
|
||||||
speechModelsGet: 'settings:speech-models:get',
|
speechModelsGet: 'settings:speech-models:get',
|
||||||
speechModelsInstall: 'settings:speech-models:install',
|
speechModelsInstall: 'settings:speech-models:install',
|
||||||
speechModelsCancel: 'settings:speech-models:cancel',
|
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 [
|
for (const [path, content] of [
|
||||||
['GoodBuddy.exe', 'MZ'],
|
['GoodBuddy.exe', 'MZ'],
|
||||||
['resources/app.asar', 'asar'],
|
['resources/app.asar', 'asar'],
|
||||||
|
['resources/release-notes.json', '{}'],
|
||||||
['resources/icon.ico', 'icon'],
|
['resources/icon.ico', 'icon'],
|
||||||
['resources/tray-icon.png', 'tray'],
|
['resources/tray-icon.png', 'tray'],
|
||||||
['resources/runtimes/opencode/opencode.exe', 'MZ'],
|
['resources/runtimes/opencode/opencode.exe', 'MZ'],
|
||||||
|
|||||||
Reference in New Issue
Block a user