feat: add bilingual release notes

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