mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
be4984009c
commit
978dfb2efb
2
.github/workflows/release-desktop.yml
vendored
2
.github/workflows/release-desktop.yml
vendored
@ -398,8 +398,10 @@ jobs:
|
|||||||
fail_on_unmatched_files: true
|
fail_on_unmatched_files: true
|
||||||
files: |
|
files: |
|
||||||
artifacts/release-assets/**/*.dmg
|
artifacts/release-assets/**/*.dmg
|
||||||
|
artifacts/release-assets/**/*.zip
|
||||||
artifacts/release-assets/**/*.exe
|
artifacts/release-assets/**/*.exe
|
||||||
artifacts/release-assets/**/*.AppImage
|
artifacts/release-assets/**/*.AppImage
|
||||||
artifacts/release-assets/**/*.deb
|
artifacts/release-assets/**/*.deb
|
||||||
|
artifacts/release-assets/**/*.blockmap
|
||||||
artifacts/update-metadata-standard/*.yml
|
artifacts/update-metadata-standard/*.yml
|
||||||
desktop/scripts/install-macos-unsigned.sh
|
desktop/scripts/install-macos-unsigned.sh
|
||||||
|
|||||||
@ -112,6 +112,12 @@ describe('formatToolUse', () => {
|
|||||||
expect(result).toContain('🔧 Bash')
|
expect(result).toContain('🔧 Bash')
|
||||||
expect(result).toContain('npm test')
|
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', () => {
|
describe('formatPermissionRequest', () => {
|
||||||
|
|||||||
@ -88,6 +88,25 @@ describe('SessionStore', () => {
|
|||||||
expect(store2.get('anything')).toBeNull()
|
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', () => {
|
it('lists all entries', () => {
|
||||||
store.set('chat-1', 'uuid-1', '/a')
|
store.set('chat-1', 'uuid-1', '/a')
|
||||||
store.set('chat-2', 'uuid-2', '/b')
|
store.set('chat-2', 'uuid-2', '/b')
|
||||||
|
|||||||
@ -50,6 +50,10 @@
|
|||||||
"target": "nsis",
|
"target": "nsis",
|
||||||
"icon": "src-tauri/icons/icon.ico"
|
"icon": "src-tauri/icons/icon.ico"
|
||||||
},
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true
|
||||||
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"target": [
|
"target": [
|
||||||
"AppImage",
|
"AppImage",
|
||||||
|
|||||||
@ -1387,6 +1387,41 @@ describe('Settings > About tab', () => {
|
|||||||
expect(screen.getByText('Added markdown support')).toBeInTheDocument()
|
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 () => {
|
it('shows downloaded bytes instead of a fake zero percent when total size is unknown', async () => {
|
||||||
useUpdateStore.setState({
|
useUpdateStore.setState({
|
||||||
status: 'downloading',
|
status: 'downloading',
|
||||||
|
|||||||
@ -3652,7 +3652,7 @@ function AboutSettings() {
|
|||||||
if (!cancelled) setVersion(value)
|
if (!cancelled) setVersion(value)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setVersion('0.1.0')
|
if (!cancelled) setVersion('')
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@ -74,6 +74,43 @@ describe('updateStore', () => {
|
|||||||
expect(useUpdateStore.getState().shouldPrompt).toBe(true)
|
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 () => {
|
it('checks, installs, and relaunches through an injected desktop host', async () => {
|
||||||
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
|
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
|
||||||
const download = vi.fn(async (onEvent?: (event: unknown) => void) => {
|
const download = vi.fn(async (onEvent?: (event: unknown) => void) => {
|
||||||
|
|||||||
@ -117,6 +117,45 @@ function getErrorMessage(error: unknown) {
|
|||||||
return error instanceof Error ? error.message : String(error)
|
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) => ({
|
export const useUpdateStore = create<UpdateStore>((set, get) => ({
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
availableVersion: null,
|
availableVersion: null,
|
||||||
@ -156,6 +195,28 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
const updateProxyKey = getUpdateProxyKey()
|
const updateProxyKey = getUpdateProxyKey()
|
||||||
const update = await host.updates.check(getUpdateCheckOptions())
|
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)
|
await setPendingUpdate(update, updateProxyKey)
|
||||||
|
|
||||||
const checkedAt = Date.now()
|
const checkedAt = Date.now()
|
||||||
|
|||||||
@ -191,8 +191,11 @@ describe('release desktop workflow', () => {
|
|||||||
expect(publishJob).toContain('Validate standard update metadata set')
|
expect(publishJob).toContain('Validate standard update metadata set')
|
||||||
expect(publishJob).toContain('softprops/action-gh-release@v2')
|
expect(publishJob).toContain('softprops/action-gh-release@v2')
|
||||||
expect(publishJob).toContain('artifacts/release-assets/**/*.dmg')
|
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/**/*.exe')
|
||||||
expect(publishJob).toContain('artifacts/release-assets/**/*.AppImage')
|
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('artifacts/update-metadata-standard/*.yml')
|
||||||
expect(publishJob).toContain('desktop/scripts/install-macos-unsigned.sh')
|
expect(publishJob).toContain('desktop/scripts/install-macos-unsigned.sh')
|
||||||
expect(publishJob).toContain('fail_on_unmatched_files: true')
|
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.win?.publish).toBeUndefined()
|
||||||
expect(desktopPackage.build.linux?.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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user