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