diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 29652616..f8bc8a65 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -55,7 +55,7 @@ jobs: # Windows x64 - platform: windows-latest rust_target: x86_64-pc-windows-msvc - tauri_args: '--bundles msi' + tauri_args: '--bundles nsis' label: Windows-x64 asset_name_pattern: 'Claude-Code-Haha_[version]_windows_x64_[bundle][ext]' @@ -176,6 +176,6 @@ jobs: releaseBody: ${{ steps.release_notes.outputs.body }} releaseDraft: ${{ github.event_name == 'workflow_dispatch' && inputs.draft || false }} prerelease: false - updaterJsonPreferNsis: false + updaterJsonPreferNsis: true releaseAssetNamePattern: ${{ matrix.asset_name_pattern }} args: ${{ matrix.tauri_args }} --config src-tauri/tauri.release-ci.json diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 44b68110..ee47e4b6 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -112,6 +112,22 @@ fn restart_adapters_sidecar(app: AppHandle) -> Result<(), String> { Ok(()) } +#[tauri::command] +fn prepare_for_update_install(app: AppHandle) -> Result<(), String> { + stop_server_sidecar(&app); + stop_adapters_sidecar(&app); + + #[cfg(target_os = "windows")] + { + kill_windows_sidecars(); + } + + // Give Windows a short moment to release executable file handles before the + // updater starts replacing bundled sidecars in the install directory. + std::thread::sleep(Duration::from_millis(750)); + Ok(()) +} + #[tauri::command] fn terminal_spawn( app: AppHandle, @@ -721,6 +737,21 @@ fn stop_adapters_sidecar(app: &AppHandle) { } } +#[cfg(target_os = "windows")] +fn kill_windows_sidecars() { + for image_name in [ + "claude-sidecar-x86_64-pc-windows-msvc.exe", + "claude-sidecar-aarch64-pc-windows-msvc.exe", + "claude-sidecar.exe", + ] { + let _ = StdCommand::new("taskkill") + .args(["/F", "/T", "/IM", image_name]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + #[cfg(test)] mod tests { use super::{decode_terminal_output, default_utf8_locale, ensure_utf8_locale, parse_env_block}; @@ -817,6 +848,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ get_server_url, restart_adapters_sidecar, + prepare_for_update_install, terminal_spawn, terminal_write, terminal_resize, diff --git a/desktop/src-tauri/windows-installer-hooks.nsh b/desktop/src-tauri/windows-installer-hooks.nsh index d33adbf1..0ff18713 100644 --- a/desktop/src-tauri/windows-installer-hooks.nsh +++ b/desktop/src-tauri/windows-installer-hooks.nsh @@ -1,6 +1,8 @@ !macro NSIS_HOOK_PREINSTALL - DetailPrint "Stopping running Claude Code Haha processes..." - nsExec::ExecToLog 'taskkill /F /T /IM claude-code-desktop.exe' + DetailPrint "Stopping running Claude Code Haha sidecars..." + nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-x86_64-pc-windows-msvc.exe' + Pop $0 + nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-aarch64-pc-windows-msvc.exe' Pop $0 nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar.exe' Pop $0 @@ -11,6 +13,10 @@ DetailPrint "Stopping running Claude Code Haha processes..." nsExec::ExecToLog 'taskkill /F /T /IM claude-code-desktop.exe' Pop $0 + nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-x86_64-pc-windows-msvc.exe' + Pop $0 + nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-aarch64-pc-windows-msvc.exe' + Pop $0 nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar.exe' Pop $0 Sleep 1000 diff --git a/desktop/src/stores/updateStore.test.ts b/desktop/src/stores/updateStore.test.ts index b4ccd78d..2440ba26 100644 --- a/desktop/src/stores/updateStore.test.ts +++ b/desktop/src/stores/updateStore.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const check = vi.fn() const relaunch = vi.fn() +const invoke = vi.fn() vi.mock('@tauri-apps/plugin-updater', () => ({ check, @@ -11,10 +12,15 @@ vi.mock('@tauri-apps/plugin-process', () => ({ relaunch, })) +vi.mock('@tauri-apps/api/core', () => ({ + invoke, +})) + describe('updateStore', () => { beforeEach(() => { check.mockReset() relaunch.mockReset() + invoke.mockReset() window.localStorage.clear() Object.defineProperty(window, '__TAURI_INTERNALS__', { configurable: true, @@ -89,20 +95,23 @@ describe('updateStore', () => { expect(useUpdateStore.getState().shouldPrompt).toBe(true) }) - it('computes download progress from content length and relaunches after install', async () => { - const downloadAndInstall = vi.fn(async (onEvent?: (event: unknown) => void) => { + it('downloads, stops sidecars, installs, and relaunches', async () => { + const download = vi.fn(async (onEvent?: (event: unknown) => void) => { onEvent?.({ event: 'Started', data: { contentLength: 200 } }) onEvent?.({ event: 'Progress', data: { chunkLength: 50 } }) onEvent?.({ event: 'Progress', data: { chunkLength: 150 } }) onEvent?.({ event: 'Finished' }) }) + const install = vi.fn().mockResolvedValue(undefined) check.mockResolvedValue({ version: '0.2.0', body: 'Notes', - downloadAndInstall, + download, + install, close: vi.fn().mockResolvedValue(undefined), }) + invoke.mockResolvedValue(undefined) relaunch.mockResolvedValue(undefined) vi.resetModules() @@ -111,7 +120,14 @@ describe('updateStore', () => { await useUpdateStore.getState().checkForUpdates() await useUpdateStore.getState().installUpdate() - expect(downloadAndInstall).toHaveBeenCalledTimes(1) + expect(download).toHaveBeenCalledTimes(1) + expect(invoke).toHaveBeenCalledWith('prepare_for_update_install') + expect(install).toHaveBeenCalledTimes(1) + const prepareCallOrder = invoke.mock.invocationCallOrder[0] + const installCallOrder = install.mock.invocationCallOrder[0] + expect(prepareCallOrder).toBeDefined() + expect(installCallOrder).toBeDefined() + expect(prepareCallOrder!).toBeLessThan(installCallOrder!) expect(useUpdateStore.getState().progressPercent).toBe(100) expect(useUpdateStore.getState().status).toBe('restarting') expect(relaunch).toHaveBeenCalledTimes(1) diff --git a/desktop/src/stores/updateStore.ts b/desktop/src/stores/updateStore.ts index e72b5733..b610c25c 100644 --- a/desktop/src/stores/updateStore.ts +++ b/desktop/src/stores/updateStore.ts @@ -191,11 +191,12 @@ export const useUpdateStore = create((set, get) => ({ try { writeDismissedUpdateVersion(null) + const { invoke } = await import('@tauri-apps/api/core') const { relaunch } = await import('@tauri-apps/plugin-process') let totalBytes: number | null = null let downloadedBytes = 0 - await update.downloadAndInstall((event) => { + await update.download((event) => { if (event.event === 'Started') { totalBytes = event.data.contentLength ?? null downloadedBytes = 0 @@ -226,6 +227,9 @@ export const useUpdateStore = create((set, get) => ({ } }) + await invoke('prepare_for_update_install') + await update.install() + set((state) => ({ ...state, status: 'restarting',