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) + }) })