diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml
index 93c427ad..9ceff66e 100644
--- a/.github/workflows/release-desktop.yml
+++ b/.github/workflows/release-desktop.yml
@@ -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
diff --git a/adapters/common/__tests__/format.test.ts b/adapters/common/__tests__/format.test.ts
index 0597516f..9ba63416 100644
--- a/adapters/common/__tests__/format.test.ts
+++ b/adapters/common/__tests__/format.test.ts
@@ -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', () => {
diff --git a/adapters/common/__tests__/session-store.test.ts b/adapters/common/__tests__/session-store.test.ts
index a029084e..2c74576d 100644
--- a/adapters/common/__tests__/session-store.test.ts
+++ b/adapters/common/__tests__/session-store.test.ts
@@ -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')
diff --git a/desktop/package.json b/desktop/package.json
index 64caa2a2..bd153425 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -50,6 +50,10 @@
"target": "nsis",
"icon": "src-tauri/icons/icon.ico"
},
+ "nsis": {
+ "oneClick": false,
+ "allowToChangeInstallationDirectory": true
+ },
"linux": {
"target": [
"AppImage",
diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx
index 997a2e6a..29476fe0 100644
--- a/desktop/src/__tests__/generalSettings.test.tsx
+++ b/desktop/src/__tests__/generalSettings.test.tsx
@@ -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()
+
+ 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',
diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx
index 91471e0e..68eb41e3 100644
--- a/desktop/src/pages/Settings.tsx
+++ b/desktop/src/pages/Settings.tsx
@@ -3652,7 +3652,7 @@ function AboutSettings() {
if (!cancelled) setVersion(value)
})
.catch(() => {
- if (!cancelled) setVersion('0.1.0')
+ if (!cancelled) setVersion('')
})
return () => {
diff --git a/desktop/src/stores/updateStore.test.ts b/desktop/src/stores/updateStore.test.ts
index 466acd03..96a51cba 100644
--- a/desktop/src/stores/updateStore.test.ts
+++ b/desktop/src/stores/updateStore.test.ts
@@ -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) => {
diff --git a/desktop/src/stores/updateStore.ts b/desktop/src/stores/updateStore.ts
index 244b4ff2..88199404 100644
--- a/desktop/src/stores/updateStore.ts
+++ b/desktop/src/stores/updateStore.ts
@@ -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((set, get) => ({
status: 'idle',
availableVersion: null,
@@ -156,6 +195,28 @@ export const useUpdateStore = create((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()
diff --git a/scripts/pr/release-workflow.test.ts b/scripts/pr/release-workflow.test.ts
index 5724302b..62af25c3 100644
--- a/scripts/pr/release-workflow.test.ts
+++ b/scripts/pr/release-workflow.test.ts
@@ -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)
+ })
})