fix(desktop): repair Windows updater and installer flow (#801)

Prevent stale same-version update metadata from surfacing another install prompt, avoid showing a fake desktop version fallback, and configure the Windows NSIS installer to expose install directory selection.

Fixes #801

Tested: bun run verify
Confidence: high
Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 10:39:01 +08:00
parent be4984009c
commit 978dfb2efb
9 changed files with 182 additions and 1 deletions

View File

@ -398,8 +398,10 @@ jobs:
fail_on_unmatched_files: true
files: |
artifacts/release-assets/**/*.dmg
artifacts/release-assets/**/*.zip
artifacts/release-assets/**/*.exe
artifacts/release-assets/**/*.AppImage
artifacts/release-assets/**/*.deb
artifacts/release-assets/**/*.blockmap
artifacts/update-metadata-standard/*.yml
desktop/scripts/install-macos-unsigned.sh

View File

@ -112,6 +112,12 @@ describe('formatToolUse', () => {
expect(result).toContain('🔧 Bash')
expect(result).toContain('npm test')
})
it('summarizes file tools with a shortened path', () => {
const result = formatToolUse('Read', { file_path: '/Users/test/project/src/index.ts' })
expect(result).toBe('🔧 Read …/project/src/index.ts')
})
})
describe('formatPermissionRequest', () => {

View File

@ -88,6 +88,25 @@ describe('SessionStore', () => {
expect(store2.get('anything')).toBeNull()
})
it('uses CLAUDE_CONFIG_DIR for the default session store path', () => {
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tmpDir
try {
const defaultStore = new SessionStore()
defaultStore.set('chat-default', 'uuid-default', '/default-project')
const savedPath = path.join(tmpDir, 'adapter-sessions.json')
expect(fs.existsSync(savedPath)).toBe(true)
expect(JSON.parse(fs.readFileSync(savedPath, 'utf8'))['chat-default'].sessionId).toBe('uuid-default')
} finally {
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
}
}
})
it('lists all entries', () => {
store.set('chat-1', 'uuid-1', '/a')
store.set('chat-2', 'uuid-2', '/b')

View File

@ -50,6 +50,10 @@
"target": "nsis",
"icon": "src-tauri/icons/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
},
"linux": {
"target": [
"AppImage",

View File

@ -1387,6 +1387,41 @@ describe('Settings > About tab', () => {
expect(screen.getByText('Added markdown support')).toBeInTheDocument()
})
it('does not show a fake fallback app version when desktop version IPC fails', async () => {
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
updates: true,
},
app: {
getVersion: vi.fn().mockRejectedValue(new Error('version IPC failed')),
},
}
useUpdateStore.setState({
status: 'up-to-date',
availableVersion: null,
releaseNotes: null,
progressPercent: 0,
downloadedBytes: 0,
totalBytes: null,
error: null,
checkedAt: Date.now(),
shouldPrompt: false,
initialize: vi.fn().mockResolvedValue(undefined),
checkForUpdates: vi.fn().mockResolvedValue(null),
installUpdate: vi.fn().mockResolvedValue(undefined),
dismissPrompt: vi.fn(),
})
render(<Settings />)
expect(await screen.findByText('Unknown')).toBeInTheDocument()
expect(screen.queryByText('0.1.0')).not.toBeInTheDocument()
})
it('shows downloaded bytes instead of a fake zero percent when total size is unknown', async () => {
useUpdateStore.setState({
status: 'downloading',

View File

@ -3652,7 +3652,7 @@ function AboutSettings() {
if (!cancelled) setVersion(value)
})
.catch(() => {
if (!cancelled) setVersion('0.1.0')
if (!cancelled) setVersion('')
})
return () => {

View File

@ -74,6 +74,43 @@ describe('updateStore', () => {
expect(useUpdateStore.getState().shouldPrompt).toBe(true)
})
it('treats same-version update metadata as already up to date', async () => {
const download = vi.fn().mockResolvedValue(undefined)
const close = vi.fn().mockResolvedValue(undefined)
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
updates: true,
},
app: {
getVersion: vi.fn().mockResolvedValue('0.4.1'),
},
updates: {
...browserHost.updates,
check: vi.fn().mockResolvedValue({
version: '0.4.1',
body: 'Already installed',
download,
close,
}),
},
}
vi.resetModules()
const { useUpdateStore } = await import('./updateStore')
await expect(useUpdateStore.getState().checkForUpdates()).resolves.toBeNull()
expect(download).not.toHaveBeenCalled()
expect(close).toHaveBeenCalledTimes(1)
expect(useUpdateStore.getState().status).toBe('up-to-date')
expect(useUpdateStore.getState().availableVersion).toBeNull()
expect(useUpdateStore.getState().shouldPrompt).toBe(false)
})
it('checks, installs, and relaunches through an injected desktop host', async () => {
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
const download = vi.fn(async (onEvent?: (event: unknown) => void) => {

View File

@ -117,6 +117,45 @@ function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
function parseAppVersion(version: string | null | undefined) {
const match = version?.trim().replace(/^v/i, '').match(/^(\d+)\.(\d+)\.(\d+)/)
if (!match) return null
return match.slice(1).map(Number) as [number, number, number]
}
function compareAppVersions(left: string | null | undefined, right: string | null | undefined) {
const leftParts = parseAppVersion(left)
const rightParts = parseAppVersion(right)
if (!leftParts || !rightParts) return null
for (let index = 0; index < leftParts.length; index += 1) {
const delta = leftParts[index]! - rightParts[index]!
if (delta !== 0) return delta
}
return 0
}
function isUpdateNewerThanCurrent(updateVersion: string, currentVersion: string | null) {
const comparison = compareAppVersions(updateVersion, currentVersion)
return comparison === null || comparison > 0
}
async function getCurrentAppVersion(host: DesktopHost) {
try {
return await host.app.getVersion()
} catch {
return null
}
}
async function closeIgnoredUpdate(update: DesktopUpdate) {
try {
await update.close()
} catch {
// Best effort: a stale same-version update should not keep the prompt alive.
}
}
export const useUpdateStore = create<UpdateStore>((set, get) => ({
status: 'idle',
availableVersion: null,
@ -156,6 +195,28 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
try {
const updateProxyKey = getUpdateProxyKey()
const update = await host.updates.check(getUpdateCheckOptions())
if (update && !isUpdateNewerThanCurrent(update.version, await getCurrentAppVersion(host))) {
await closeIgnoredUpdate(update)
await setPendingUpdate(null, null)
const checkedAt = Date.now()
writeDismissedUpdateVersion(null)
set((state) => ({
...state,
status: 'up-to-date',
availableVersion: null,
releaseNotes: null,
progressPercent: 0,
downloadedBytes: 0,
totalBytes: null,
checkedAt,
error: null,
shouldPrompt: false,
}))
return null
}
await setPendingUpdate(update, updateProxyKey)
const checkedAt = Date.now()

View File

@ -191,8 +191,11 @@ describe('release desktop workflow', () => {
expect(publishJob).toContain('Validate standard update metadata set')
expect(publishJob).toContain('softprops/action-gh-release@v2')
expect(publishJob).toContain('artifacts/release-assets/**/*.dmg')
expect(publishJob).toContain('artifacts/release-assets/**/*.zip')
expect(publishJob).toContain('artifacts/release-assets/**/*.exe')
expect(publishJob).toContain('artifacts/release-assets/**/*.AppImage')
expect(publishJob).toContain('artifacts/release-assets/**/*.deb')
expect(publishJob).toContain('artifacts/release-assets/**/*.blockmap')
expect(publishJob).toContain('artifacts/update-metadata-standard/*.yml')
expect(publishJob).toContain('desktop/scripts/install-macos-unsigned.sh')
expect(publishJob).toContain('fail_on_unmatched_files: true')
@ -316,4 +319,18 @@ describe('release desktop workflow', () => {
expect(desktopPackage.build.win?.publish).toBeUndefined()
expect(desktopPackage.build.linux?.publish).toBeUndefined()
})
test('Windows NSIS installer lets users choose the install directory', () => {
const desktopPackage = JSON.parse(readFileSync('desktop/package.json', 'utf8')) as {
build: {
nsis?: {
oneClick?: boolean
allowToChangeInstallationDirectory?: boolean
}
}
}
expect(desktopPackage.build.nsis?.oneClick).toBe(false)
expect(desktopPackage.build.nsis?.allowToChangeInstallationDirectory).toBe(true)
})
})