diff --git a/bin/arguments.js b/bin/arguments.js index 1ade38c5..9a681710 100644 --- a/bin/arguments.js +++ b/bin/arguments.js @@ -120,6 +120,7 @@ const usageExamples = [ description: 'Review the open pull request for a branch.', }, { command: 'codiff mr 75', description: 'Review GitLab merge request !75.' }, + { command: 'codiff update', description: 'Update Codiff to the latest release.' }, { command: 'codiff --plan plan.md', description: 'Edit a plan and wait for handoff.' }, { command: 'codiff --plan plan.md --share', description: 'Share a Markdown plan.' }, { command: 'codiff -w', description: 'Walk through local changes, or HEAD when clean.' }, diff --git a/bin/codiff-app b/bin/codiff-app index 900ddb64..1c7b75d9 100755 --- a/bin/codiff-app +++ b/bin/codiff-app @@ -20,6 +20,80 @@ codiff_version() { sed -n 's/^ "version": "\([^"]*\)".*/\1/p' "$script_dir/../package.json" } +# major.minor.patch integer compare; any other shape is "not newer". +is_newer_version() { + case "$1$2" in *[!0-9.]*|'') return 1 ;; esac + case ".$1." in *..*) return 1 ;; esac + case ".$2." in *..*) return 1 ;; esac + old_ifs="$IFS" + IFS=. + # shellcheck disable=SC2086 + set -- $1 -- $2 + IFS="$old_ifs" + [ "$#" -eq 7 ] && [ "$4" = "--" ] || return 1 + # sh integer tests overflow beyond int64; nothing that large is a release. + for version_component in "$1" "$2" "$3" "$5" "$6" "$7"; do + [ "${#version_component}" -le 9 ] || return 1 + done + latest_major="$1" latest_minor="$2" latest_patch="$3" + current_major="$5" current_minor="$6" current_patch="$7" + if [ "$latest_major" -ne "$current_major" ]; then + [ "$latest_major" -gt "$current_major" ] + return $? + fi + if [ "$latest_minor" -ne "$current_minor" ]; then + [ "$latest_minor" -gt "$current_minor" ] + return $? + fi + [ "$latest_patch" -gt "$current_patch" ] +} + +# Mirror of the Node notice's Date.parse gate, restricted to the ISO shape +# the app writes: YYYY-MM-DDTHH:MM:SS with in-range components, optionally +# followed by a .mmm fraction and Z. Anything else, including timezone +# offsets Date.parse would accept, stays silent. Day-in-month overflow such +# as February 30th passes, matching the Date.parse rollover behavior. +is_iso_timestamp() { + case "$1" in + [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-5][0-9]:[0-5][0-9]*) ;; + *) return 1 ;; + esac + iso_month="${1#????-}" + iso_month="${iso_month%%-*}" + iso_day="${1#????-??-}" + iso_day="${iso_day%%T*}" + iso_hour="${1#????-??-??T}" + iso_hour="${iso_hour%%:*}" + iso_tail="${1#????-??-??T??:??:??}" + case "$iso_month" in 0[1-9]|1[0-2]) ;; *) return 1 ;; esac + case "$iso_day" in 0[1-9]|[12][0-9]|3[01]) ;; *) return 1 ;; esac + case "$iso_hour" in [01][0-9]|2[0-3]) ;; *) return 1 ;; esac + case "$iso_tail" in ''|Z|.[0-9][0-9][0-9]|.[0-9][0-9][0-9]Z) ;; *) return 1 ;; esac +} + +# Mirror of bin/update-notice.js for the wrapper-owned code paths: read the +# state cached by the app's daily check and never touch the network. The +# notice is best effort: a missing, unreadable, or corrupt cache must stay +# silent and must never change the wrapped command's exit status. +print_update_notice() { + [ -n "${HOME:-}" ] || return 0 + state_file="$HOME/.codiff/update-state.json" + [ -f "$state_file" ] && [ -r "$state_file" ] || return 0 + # tail -n 1 mirrors JSON duplicate-key last-wins resolution. + checked_at="$(sed -n 's/.*"lastCheckedAt"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$state_file" 2>/dev/null | tail -n 1)" + latest="$(sed -n 's/.*"latestVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$state_file" 2>/dev/null | tail -n 1)" + dismissed="$(sed -n 's/.*"dismissedVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$state_file" 2>/dev/null | tail -n 1)" + is_iso_timestamp "$checked_at" || return 0 + [ -n "$latest" ] || return 0 + [ "$latest" = "$dismissed" ] && return 0 + current="$(codiff_version)" + is_newer_version "$latest" "$current" || return 0 + # The backticks are literal command quoting, not a missed expansion. + # shellcheck disable=SC2016 + printf 'A new version of Codiff is available (v%s -> v%s). Run `codiff update` to update.\n' \ + "$current" "$latest" >&2 +} + show_help() { version="$(codiff_version)" blue_bold="$(printf '\033[1;34m')" @@ -30,6 +104,9 @@ show_help() { printf '\n' printf '%s\n' "${blue_bold}Usage:${reset} ${gray}codiff [options] [ | | ] [path]${reset}" printf '\n' + printf '%s\n' "${blue_bold}Commands:${reset}" + printf ' %-38s%s%s%s\n' "update" "$gray" "Update Codiff to the latest release." "$reset" + printf '\n' printf '%s\n' "${blue_bold}Options:${reset}" printf ' %-38s%s%s%s\n' "--agent " "$gray" "Override the agent backend for this session." "$reset" printf ' %-38s%s%s%s\n' "--branch " "$gray" "Review the current branch against a target branch." "$reset" @@ -65,22 +142,32 @@ show_help() { printf ' %-32s%s%s%s\n' "codiff --share HEAD" "$gray" "Share a walkthrough for a commit." "$reset" } +resolve_runtime() { + runtime="${CODIFF_NODE_COMMAND:-}" + if [ -z "$runtime" ]; then + electron_exec="$(ls "$app_path/Contents/MacOS/" 2>/dev/null | head -n 1)" + if [ -n "$electron_exec" ]; then + runtime="$app_path/Contents/MacOS/$electron_exec" + fi + fi + if [ -z "$runtime" ] || [ ! -x "$runtime" ]; then + printf '%s\n' "codiff: could not find the bundled runtime for $1." >&2 + exit 1 + fi +} + +# `codiff update` checks GitHub Releases and updates this install in place. +if [ "$#" -eq 1 ] && [ "$1" = "update" ]; then + resolve_runtime update + ELECTRON_RUN_AS_NODE=1 exec "$runtime" "$script_dir/codiff.js" update +fi + # Flags the Node entry owns: it does the work and prints the result, so this # helper hands the whole command line over to the bundled runtime. for arg in "$@"; do case "$arg" in --share|--public|--completions|--completions=*) - runtime="${CODIFF_NODE_COMMAND:-}" - if [ -z "$runtime" ]; then - electron_exec="$(ls "$app_path/Contents/MacOS/" 2>/dev/null | head -n 1)" - if [ -n "$electron_exec" ]; then - runtime="$app_path/Contents/MacOS/$electron_exec" - fi - fi - if [ -z "$runtime" ] || [ ! -x "$runtime" ]; then - printf '%s\n' "codiff: could not find the bundled runtime for $arg." >&2 - exit 1 - fi + resolve_runtime "$arg" ELECTRON_RUN_AS_NODE=1 exec "$runtime" "$script_dir/codiff.js" "$@" ;; esac @@ -259,6 +346,7 @@ for arg in "$@"; do ;; --version|-v) printf 'codiff v%s\n' "$(codiff_version)" + print_update_notice exit 0 ;; --walkthrough-guide) @@ -487,6 +575,8 @@ open_codiff() { open -n "$app_path" --args "$@" } +print_update_notice + if [ -n "$plan_file_path" ]; then if [ -z "$plan_result_file_path" ]; then plan_result_directory="$(mktemp -d "${TMPDIR:-/tmp}/codiff-plan-result.XXXXXX")" diff --git a/bin/codiff.js b/bin/codiff.js index 51d3387f..8e099aea 100755 --- a/bin/codiff.js +++ b/bin/codiff.js @@ -17,6 +17,8 @@ import { } from './arguments.js'; import { completionShells, generateCompletionScript } from './completions.js'; import { waitForPlanResult } from './plan-result.js'; +import { runUpdateCommand } from './update-command.js'; +import { getUpdateNotice } from './update-notice.js'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const require = createRequire(import.meta.url); @@ -105,7 +107,30 @@ const buildWalkthroughGuide = () => { return `${guide}\n\n\`\`\`json\n${JSON.stringify(narrativeWalkthroughSchema, null, 2)}\n\`\`\`\n`; }; +const runCodiffUpdate = () => { + const appBundleMatch = process.platform === 'darwin' ? root.match(/^(.*\.app)\//) : null; + return runUpdateCommand({ + currentVersion: packageJson.version, + isSourceCheckout: existsSync(resolve(root, '.git')), + log: (line) => process.stdout.write(`${line}\n`), + openApp: appBundleMatch + ? () => { + spawn('open', ['-n', appBundleMatch[1], '--args', '--apply-update'], { + detached: true, + stdio: 'ignore', + }).unref(); + } + : null, + }); +}; + const run = async () => { + const rawArguments = process.argv.slice(2); + if (rawArguments.length === 1 && rawArguments[0] === 'update') { + process.exitCode = await runCodiffUpdate(); + return; + } + const parsedArguments = parseArguments(process.argv.slice(2)); if (parsedArguments.help) { @@ -115,6 +140,10 @@ const run = async () => { if (parsedArguments.version) { process.stdout.write(`codiff v${packageJson.version}\n`); + const notice = getUpdateNotice({ currentVersion: packageJson.version }); + if (notice) { + process.stderr.write(`${notice}\n`); + } return; } @@ -296,6 +325,11 @@ const run = async () => { delete childEnv.ELECTRON_RUN_AS_NODE; delete childEnv.ELECTRON_NO_ATTACH_CONSOLE; + const updateNotice = getUpdateNotice({ currentVersion: packageJson.version }); + if (updateNotice) { + process.stderr.write(`${updateNotice}\n`); + } + const { default: electron } = await import('electron'); const child = spawn(electron, [root], { diff --git a/bin/update-command.js b/bin/update-command.js new file mode 100644 index 00000000..2839942f --- /dev/null +++ b/bin/update-command.js @@ -0,0 +1,88 @@ +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { + fetchLatestRelease, + isNewerVersion, + releasePageUrl, +} = require('../electron/update-check.cjs'); + +/** + * Decide what `codiff update` should do for this install. + * + * Homebrew-owned installs are not special-cased: the cask is marked + * auto_updates, so the app updating itself in place is the supported path and + * stays ahead of a tap that may lag behind the newest release. + * + * @param {{ + * currentVersion: string; + * isSourceCheckout: boolean; + * latestVersion: string; + * }} options + * @returns {{ kind: 'up-to-date' } | { kind: 'open-app' | 'source-checkout'; version: string }} + */ +export function resolveUpdateAction({ currentVersion, isSourceCheckout, latestVersion }) { + if (!isNewerVersion(latestVersion, currentVersion)) { + return { kind: 'up-to-date' }; + } + + return isSourceCheckout + ? { kind: 'source-checkout', version: latestVersion } + : { kind: 'open-app', version: latestVersion }; +} + +/** + * Check GitHub Releases and perform the platform-appropriate update. + * + * @param {{ + * currentVersion: string; + * isSourceCheckout: boolean; + * log: (line: string) => void; + * openApp: (() => void) | null; + * releaseUrl?: string; + * }} options + * @returns {Promise} + */ +export async function runUpdateCommand({ + currentVersion, + isSourceCheckout, + log, + openApp, + releaseUrl, +}) { + let latestVersion; + try { + latestVersion = (await fetchLatestRelease(releaseUrl)).version; + } catch (error) { + log( + `codiff: could not check for updates: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return 1; + } + + const action = resolveUpdateAction({ currentVersion, isSourceCheckout, latestVersion }); + + switch (action.kind) { + case 'up-to-date': + log(`codiff v${currentVersion} is up to date.`); + return 0; + case 'source-checkout': + log( + `Codiff v${action.version} is available, but this is a source checkout. Run git pull and rebuild instead.`, + ); + return 0; + case 'open-app': + if (openApp) { + log(`Opening Codiff to install v${action.version}…`); + openApp(); + return 0; + } + + log( + `Codiff v${action.version} is available. Download it from ${releasePageUrl(action.version)} or open the app and use the update banner.`, + ); + return 1; + } +} diff --git a/bin/update-notice.js b/bin/update-notice.js new file mode 100644 index 00000000..0f8213b4 --- /dev/null +++ b/bin/update-notice.js @@ -0,0 +1,18 @@ +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { getAvailableUpdate, readUpdateState } = require('../electron/update-check.cjs'); + +/** + * Build a one-line update notice from the state cached by the app's daily + * check. Never performs network requests so CLI startup stays instant. + * + * @param {{ configDir?: string; currentVersion: string }} options + * @returns {string | null} + */ +export function getUpdateNotice({ configDir, currentVersion }) { + const update = getAvailableUpdate(readUpdateState(configDir), currentVersion); + return update + ? `A new version of Codiff is available (v${currentVersion} -> v${update.version}). Run \`codiff update\` to update.` + : null; +} diff --git a/config/defaults.json b/config/defaults.json index d7d43572..cc4f880e 100644 --- a/config/defaults.json +++ b/config/defaults.json @@ -1,6 +1,7 @@ { "settings": { "agentBackend": "codex", + "checkForUpdates": true, "claudeModel": "claude-sonnet-4-6", "codeFontFamily": "", "codeFontSize": 13, diff --git a/core/App.tsx b/core/App.tsx index 18e47076..ea72ec74 100644 --- a/core/App.tsx +++ b/core/App.tsx @@ -18,6 +18,7 @@ import { RepositoryChangeBanner, RepositoryLoadErrorPanel, ReviewSourceLoading, + UpdatePill, WalkthroughOutdatedBanner, } from './app/components/Panels.tsx'; import { PlanEditorView } from './app/components/PlanEditorView.tsx'; @@ -111,6 +112,7 @@ import type { CodiffLaunchOptions, CodiffMarkdownDocument, CodiffPreferences, + CodiffUpdateStatus, GitIdentity, HistoryEntry, OpenReviewSourceKind, @@ -181,6 +183,7 @@ export default function App() { const [historyLoading, setHistoryLoading] = useState(false); const [historySource, setHistorySource] = useState(null); const [localChangesDetected, setLocalChangesDetected] = useState(false); + const [updateStatus, setUpdateStatus] = useState(null); const [launchOptions, setLaunchOptions] = useState(defaultLaunchOptions); const [codiffConfig, setCodiffConfig] = useState(createDefaultConfig); const [agentSkillInstalling, setAgentSkillInstalling] = useState(false); @@ -814,6 +817,25 @@ export default function App() { [], ); + useEffect(() => { + let canceled = false; + + window.codiff + .getUpdateStatus() + .then((status) => { + if (!canceled) { + setUpdateStatus(status); + } + }) + .catch(() => {}); + + const unsubscribe = window.codiff.onUpdateStatusChanged(setUpdateStatus); + return () => { + canceled = true; + unsubscribe(); + }; + }, []); + useEffect(() => { let canceled = false; @@ -1921,6 +1943,14 @@ export default function App() { walkthroughLoading={walkthroughLoading} walkthroughProgress={walkthroughProgress} /> + {updateStatus ? ( + { + window.codiff.applyUpdate().then(setUpdateStatus, () => {}); + }} + status={updateStatus} + /> + ) : null}
diff --git a/core/Desktop.css b/core/Desktop.css index 37796b76..179dc6c3 100644 --- a/core/Desktop.css +++ b/core/Desktop.css @@ -61,3 +61,85 @@ height: 1px; margin: 3px 6px; } + +/* The update pill is desktop-only chrome, so its styles live here instead of + the shared App.css that ships to the web app. */ +:root { + /* One step darker than the VS Code / Codex update blue so 12px white text + clears the WCAG AA 4.5:1 contrast ratio. */ + --update-accent: rgb(89 115 191); + --update-accent-contrast: #ffffff; +} + +.update-pill-anchor { + -webkit-app-region: no-drag; + border-top: 1px solid var(--sidebar-border); + display: flex; + flex: none; + justify-content: center; + padding: 8px; + position: relative; +} + +/* The pill is a regular .codiff-button; these rules only give it the accent + fill, so the chained selectors must outrank the shared button defaults. */ +.update-pill.codiff-button { + animation: update-pill-in 360ms cubic-bezier(0.2, 0.82, 0.22, 1) both; + background: var(--update-accent); + border-color: color-mix(in srgb, var(--update-accent) 60%, var(--text) 14%); + color: var(--update-accent-contrast); + max-width: 100%; +} + +.update-pill.codiff-button:hover:not(:disabled) { + background: color-mix(in srgb, var(--update-accent) 90%, #000000); +} + +.update-pill.codiff-button:disabled { + cursor: default; +} + +.update-pill.codiff-button.available { + animation: + update-pill-in 360ms cubic-bezier(0.2, 0.82, 0.22, 1) both, + update-pill-glow 2.6s ease-in-out 460ms 3; +} + +.update-pill.codiff-button.error { + background: color-mix(in srgb, var(--danger) 82%, var(--text) 6%); + border-color: color-mix(in srgb, var(--danger) 60%, var(--text) 14%); +} + +.update-pill.codiff-button.error:hover:not(:disabled) { + background: color-mix(in srgb, var(--danger) 78%, #000000); +} + +.update-pill-spinner { + animation: update-pill-spin 900ms linear infinite; +} + +@keyframes update-pill-in { + from { + opacity: 0; + transform: translateY(8px); + } +} + +@keyframes update-pill-glow { + 50% { + box-shadow: 0 0 0 4px color-mix(in srgb, var(--update-accent) 16%, transparent); + } +} + +@keyframes update-pill-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .update-pill, + .update-pill-spinner { + animation: none; + } +} diff --git a/core/__tests__/App-render.test.tsx b/core/__tests__/App-render.test.tsx index c8f94e3f..6b96bb24 100644 --- a/core/__tests__/App-render.test.tsx +++ b/core/__tests__/App-render.test.tsx @@ -136,6 +136,10 @@ const createCommitMetadataFixture = (body: string): CommitMetadata => ({ }); const createCodiffMock = (overrides: Partial = {}): Window['codiff'] => ({ + applyUpdate: vi.fn(async () => ({ + currentVersion: '1.9.2', + phase: 'idle' as const, + })), askReviewAssistant: vi.fn(async () => ({ reason: 'Unavailable in tests.', status: 'unavailable' as const, @@ -146,6 +150,10 @@ const createCodiffMock = (overrides: Partial = {}): Window['co status: 'committed' as const, })), decreaseCodeFontSize: vi.fn(async () => {}), + dismissUpdate: vi.fn(async () => ({ + currentVersion: '1.9.2', + phase: 'idle' as const, + })), getAgentSkillStatus: vi.fn(async () => ({ installed: true, path: '/Users/reviewer/.codex/skills/codiff', @@ -212,6 +220,10 @@ const createCodiffMock = (overrides: Partial = {}): Window['co installed: true, path: '/usr/local/bin/codiff', })), + getUpdateStatus: vi.fn(async () => ({ + currentVersion: '1.9.2', + phase: 'idle' as const, + })), increaseCodeFontSize: vi.fn(async () => {}), installAgentSkill: vi.fn(async () => ({ installed: true, @@ -233,11 +245,13 @@ const createCodiffMock = (overrides: Partial = {}): Window['co onPlanCloseRequested: vi.fn(() => () => {}), onRefreshRequest: vi.fn(() => () => {}), onRepositoryChanged: vi.fn(() => () => {}), + onUpdateStatusChanged: vi.fn(() => () => {}), onWalkthroughCommitOutput: vi.fn(() => () => {}), onWalkthroughProgress: vi.fn(() => () => {}), onWindowFullScreenChanged: vi.fn(() => () => {}), openConfigFile: vi.fn(async () => {}), openFile: vi.fn(async () => {}), + openReleasePage: vi.fn(async () => {}), openRepositoryFolder: vi.fn(async () => {}), resetCodeFontSize: vi.fn(async () => {}), resolvePullRequestUrl: vi.fn(async () => 'https://github.com/owner/repo/pull/1'), diff --git a/core/__tests__/UpdatePill.test.tsx b/core/__tests__/UpdatePill.test.tsx new file mode 100644 index 00000000..68e83421 --- /dev/null +++ b/core/__tests__/UpdatePill.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { expect, test } from 'vite-plus/test'; +import { UpdatePill, type UpdateStatus } from '../app/components/Panels.tsx'; + +const reactEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; +reactEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + +const renderPill = async (element: React.ReactNode) => { + const container = document.createElement('div'); + document.body.append(container); + let root: Root | null = null; + + await act(async () => { + root = createRoot(container); + root.render(element); + }); + + return { + container, + async [Symbol.asyncDispose]() { + await act(async () => root?.unmount()); + container.remove(); + }, + }; +}; + +const noop = () => {}; + +const status = (partial: Partial): UpdateStatus => ({ + currentVersion: '1.9.2', + phase: 'idle', + ...partial, +}); + +const pill = (view: { container: HTMLElement }) => + view.container.querySelector('.update-pill'); + +test('renders nothing while no update is available', async () => { + await using view = await renderPill( + , + ); + + expect(pill(view)).toBeNull(); +}); + +test('shows a single Update button when an update is available', async () => { + await using view = await renderPill( + , + ); + + expect(pill(view)?.textContent).toContain('Update'); + expect(pill(view)?.title).toContain('1.9.2'); + expect(pill(view)?.title).toContain('1.9.3'); + expect(view.container.querySelector('.update-popover')).toBeNull(); +}); + +test('promises a restart only when Squirrel will deliver one', async () => { + await using view = await renderPill( + , + ); + + expect(pill(view)?.title).toContain('restarts the app'); +}); + +test('describes the hand-off for the download strategy', async () => { + await using view = await renderPill( + , + ); + + expect(pill(view)?.title).toContain('opens the update'); + expect(pill(view)?.title).not.toContain('restarts'); +}); + +test('sends the user to the download page for the manual strategy', async () => { + await using view = await renderPill( + , + ); + + expect(pill(view)?.title).toContain('download page'); + expect(pill(view)?.title).not.toContain('restarts'); + expect(pill(view)?.title).not.toContain('Downloads'); +}); + +test('promises nothing beyond the download without a known strategy', async () => { + await using view = await renderPill( + , + ); + + expect(pill(view)?.title).toContain('Downloads the update.'); + expect(pill(view)?.title).not.toContain('restarts'); + expect(pill(view)?.title).not.toContain('opens'); +}); + +test('styles the pill like the shared buttons', async () => { + await using view = await renderPill( + , + ); + + expect(pill(view)?.className).toContain('codiff-button'); +}); + +test('applies the update with a single click', async () => { + let applied = 0; + await using view = await renderPill( + applied++} + status={status({ phase: 'available', version: '1.9.3' })} + />, + ); + + await act(async () => pill(view)?.click()); + + expect(applied).toBe(1); + expect(view.container.querySelector('.update-popover')).toBeNull(); +}); + +test('shows progress while updating and ignores clicks', async () => { + let applied = 0; + await using view = await renderPill( + applied++} + status={status({ phase: 'updating', version: '1.9.3' })} + />, + ); + + expect(pill(view)?.textContent).toContain('Updating'); + expect(pill(view)?.disabled).toBe(true); + + await act(async () => pill(view)?.click()); + + expect(applied).toBe(0); +}); + +test('shows progress without a version after an error retry', async () => { + await using view = await renderPill( + , + ); + + expect(pill(view)?.textContent).toContain('Updating'); + expect(pill(view)?.textContent).not.toContain('undefined'); + expect(pill(view)?.title).not.toContain('undefined'); +}); + +test('tells the user to finish a handed-off install', async () => { + let applied = 0; + await using view = await renderPill( + applied++} + status={status({ phase: 'installerReady', version: '1.9.3' })} + />, + ); + + expect(pill(view)?.textContent).toContain('Quit to finish update'); + expect(pill(view)?.title).toContain('installer'); + expect(pill(view)?.disabled).toBe(true); + + await act(async () => pill(view)?.click()); + + expect(applied).toBe(0); +}); + +test('retries a failed update with a single click', async () => { + let applied = 0; + await using view = await renderPill( + applied++} + status={status({ message: 'feed unreachable', phase: 'error', version: '1.9.3' })} + />, + ); + + expect(pill(view)?.textContent).toContain('Update failed'); + expect(pill(view)?.textContent).toContain('Try again'); + expect(pill(view)?.title).toContain('feed unreachable'); + + await act(async () => pill(view)?.click()); + + expect(applied).toBe(1); +}); diff --git a/core/__tests__/codiff-app-notice.test.ts b/core/__tests__/codiff-app-notice.test.ts new file mode 100644 index 00000000..c4c88875 --- /dev/null +++ b/core/__tests__/codiff-app-notice.test.ts @@ -0,0 +1,160 @@ +import { execFile } from 'node:child_process'; +import { chmod, mkdir, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { expect, test } from 'vite-plus/test'; +import { createTemporaryDirectory } from './helpers/resources.ts'; + +const execFileAsync = promisify(execFile); + +const wrapperPath = resolve('bin/codiff-app'); + +const runVersion = async (home: string) => + execFileAsync('sh', [wrapperPath, '--version'], { + encoding: 'utf8', + env: { ...process.env, HOME: home }, + }); + +const writeState = async ( + home: string, + state: { dismissedVersion?: string; lastCheckedAt: string; latestVersion: string }, +) => writeStateContents(home, JSON.stringify(state, null, 2)); + +const writeStateContents = async (home: string, contents: string) => { + await mkdir(join(home, '.codiff'), { recursive: true }); + await writeFile(join(home, '.codiff', 'update-state.json'), contents); +}; + +test('prints no notice without cached update state', async () => { + await using home = await createTemporaryDirectory('codiff-app-home-'); + + const { stderr, stdout } = await runVersion(home.path); + + expect(stdout).toContain('codiff v'); + expect(stderr).toBe(''); +}); + +test('prints the cached update notice on --version', async () => { + await using home = await createTemporaryDirectory('codiff-app-home-'); + await writeState(home.path, { + lastCheckedAt: '2026-07-29T00:00:00.000Z', + latestVersion: '99.0.0', + }); + + const { stderr, stdout } = await runVersion(home.path); + + expect(stdout).toContain('codiff v'); + expect(stderr).toContain('99.0.0'); + expect(stderr).toContain('codiff update'); +}); + +test('prints no notice for a dismissed version', async () => { + await using home = await createTemporaryDirectory('codiff-app-home-'); + await writeState(home.path, { + dismissedVersion: '99.0.0', + lastCheckedAt: '2026-07-29T00:00:00.000Z', + latestVersion: '99.0.0', + }); + + expect((await runVersion(home.path)).stderr).toBe(''); +}); + +test('prints no notice when the cached version is not newer', async () => { + await using home = await createTemporaryDirectory('codiff-app-home-'); + await writeState(home.path, { + lastCheckedAt: '2026-07-29T00:00:00.000Z', + latestVersion: '0.0.1', + }); + + expect((await runVersion(home.path)).stderr).toBe(''); +}); + +test('ignores malformed cached versions', async () => { + await using home = await createTemporaryDirectory('codiff-app-home-'); + await writeState(home.path, { + lastCheckedAt: '2026-07-29T00:00:00.000Z', + latestVersion: 'not-a-version', + }); + + expect((await runVersion(home.path)).stderr).toBe(''); +}); + +test('succeeds without a notice when HOME is unset', async () => { + const env = { ...process.env }; + delete env.HOME; + + const { stderr, stdout } = await execFileAsync('sh', [wrapperPath, '--version'], { + encoding: 'utf8', + env, + }); + + expect(stdout).toContain('codiff v'); + expect(stderr).toBe(''); +}); + +test('stays silent when the state file is unreadable', async () => { + await using home = await createTemporaryDirectory('codiff-app-home-'); + await writeState(home.path, { + lastCheckedAt: '2026-07-29T00:00:00.000Z', + latestVersion: '99.0.0', + }); + const stateFile = join(home.path, '.codiff', 'update-state.json'); + await chmod(stateFile, 0o000); + + try { + expect((await runVersion(home.path)).stderr).toBe(''); + } finally { + await chmod(stateFile, 0o644); + } +}); + +test('stays silent for version components beyond the shell integer range', async () => { + await using home = await createTemporaryDirectory('codiff-app-home-'); + await writeState(home.path, { + lastCheckedAt: '2026-07-29T00:00:00.000Z', + latestVersion: '99999999999999999999.0.0', + }); + + expect((await runVersion(home.path)).stderr).toBe(''); +}); + +test('stays silent when lastCheckedAt is missing', async () => { + await using home = await createTemporaryDirectory('codiff-app-home-'); + await writeStateContents(home.path, JSON.stringify({ latestVersion: '99.0.0' })); + + expect((await runVersion(home.path)).stderr).toBe(''); +}); + +test('stays silent for invalid JSON with an unparseable lastCheckedAt', async () => { + await using home = await createTemporaryDirectory('codiff-app-home-'); + await writeStateContents( + home.path, + '{\n "lastCheckedAt": "nope",\n "latestVersion": "99.0.0"\n', + ); + + expect((await runVersion(home.path)).stderr).toBe(''); +}); + +test('stays silent when lastCheckedAt does not parse as a date', async () => { + await using home = await createTemporaryDirectory('codiff-app-home-'); + + for (const lastCheckedAt of [ + '2026-07-29Tgarbage', + '2026-99-99T99:99:99.999Z', + '2026-07-29T10:00:00garbage', + ]) { + await writeState(home.path, { lastCheckedAt, latestVersion: '99.0.0' }); + + expect((await runVersion(home.path)).stderr, lastCheckedAt).toBe(''); + } +}); + +test('resolves duplicate keys last-wins like JSON parsing does', async () => { + await using home = await createTemporaryDirectory('codiff-app-home-'); + await writeStateContents( + home.path, + '{\n "lastCheckedAt": "2026-07-29T00:00:00.000Z",\n "latestVersion": "99.0.0",\n "latestVersion": "0.0.1"\n}', + ); + + expect((await runVersion(home.path)).stderr).toBe(''); +}); diff --git a/core/__tests__/codiff-update-command.test.ts b/core/__tests__/codiff-update-command.test.ts new file mode 100644 index 00000000..acc449f7 --- /dev/null +++ b/core/__tests__/codiff-update-command.test.ts @@ -0,0 +1,142 @@ +import { createServer } from 'node:http'; +import { expect, test } from 'vite-plus/test'; +import { resolveUpdateAction, runUpdateCommand } from '../../bin/update-command.js'; +import { bindDisposableHttpServer } from './helpers/resources.ts'; + +const startReleaseServer = async (version: string, statusCode = 200) => { + const server = createServer((_request, response) => { + response.statusCode = statusCode; + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ assets: [], tag_name: `v${version}` })); + }); + const disposable = await bindDisposableHttpServer(server); + const port = (server.address() as { port: number }).port; + return { disposable, url: `http://127.0.0.1:${port}/` }; +}; + +test('resolveUpdateAction reports up to date for equal or older releases', () => { + expect( + resolveUpdateAction({ + currentVersion: '1.9.2', + isSourceCheckout: false, + latestVersion: '1.9.2', + }), + ).toEqual({ kind: 'up-to-date' }); + expect( + resolveUpdateAction({ + currentVersion: '2.0.0', + isSourceCheckout: false, + latestVersion: '1.9.2', + }), + ).toEqual({ kind: 'up-to-date' }); +}); + +test('resolveUpdateAction prefers source-checkout guidance over everything', () => { + expect( + resolveUpdateAction({ + currentVersion: '1.9.2', + isSourceCheckout: true, + latestVersion: '1.9.3', + }), + ).toEqual({ kind: 'source-checkout', version: '1.9.3' }); +}); + +test('resolveUpdateAction hands every packaged install to the app self-updater', () => { + // Homebrew installs included: the cask is marked auto_updates, so the app + // updating itself in place is the supported path and brew is never probed. + expect( + resolveUpdateAction({ + currentVersion: '1.9.2', + isSourceCheckout: false, + latestVersion: '1.9.3', + }), + ).toEqual({ kind: 'open-app', version: '1.9.3' }); +}); + +test('runUpdateCommand reports up to date without touching brew or the app', async () => { + const { disposable: _server, url } = await startReleaseServer('1.9.2'); + const lines: Array = []; + + const exitCode = await runUpdateCommand({ + currentVersion: '1.9.2', + isSourceCheckout: false, + log: (line) => lines.push(line), + openApp: () => { + throw new Error('Must not open the app.'); + }, + releaseUrl: url, + }); + + expect(exitCode).toBe(0); + expect(lines.join('\n')).toContain('up to date'); +}); + +test('runUpdateCommand opens the app for non-brew installs', async () => { + const { disposable: _server, url } = await startReleaseServer('1.9.3'); + let opened = 0; + const lines: Array = []; + + const exitCode = await runUpdateCommand({ + currentVersion: '1.9.2', + isSourceCheckout: false, + log: (line) => lines.push(line), + openApp: () => { + opened++; + }, + releaseUrl: url, + }); + + expect(exitCode).toBe(0); + expect(opened).toBe(1); + expect(lines.join('\n')).toContain('1.9.3'); +}); + +test('runUpdateCommand prints manual guidance when the app cannot be opened', async () => { + const { disposable: _server, url } = await startReleaseServer('1.9.3'); + const lines: Array = []; + + const exitCode = await runUpdateCommand({ + currentVersion: '1.9.2', + isSourceCheckout: false, + log: (line) => lines.push(line), + openApp: null, + releaseUrl: url, + }); + + expect(exitCode).toBe(1); + expect(lines.join('\n')).toContain('https://github.com/nkzw-tech/codiff/releases'); +}); + +test('runUpdateCommand guides source checkouts instead of updating', async () => { + const { disposable: _server, url } = await startReleaseServer('1.9.3'); + const lines: Array = []; + + const exitCode = await runUpdateCommand({ + currentVersion: '1.9.2', + isSourceCheckout: true, + log: (line) => lines.push(line), + openApp: () => { + throw new Error('Must not open the app.'); + }, + releaseUrl: url, + }); + + expect(exitCode).toBe(0); + expect(lines.join('\n')).toContain('git pull'); +}); + +test('runUpdateCommand fails cleanly when the release check fails', async () => { + const { disposable: _server, url } = await startReleaseServer('1.9.3', 500); + const lines: Array = []; + + const exitCode = await runUpdateCommand({ + currentVersion: '1.9.2', + isSourceCheckout: false, + log: (line) => lines.push(line), + openApp: () => {}, + releaseUrl: url, + }); + + expect(exitCode).toBe(1); + expect(lines.join('\n')).toContain('could not check'); +}); diff --git a/core/__tests__/codiff-update-notice.test.ts b/core/__tests__/codiff-update-notice.test.ts new file mode 100644 index 00000000..94bc7bdb --- /dev/null +++ b/core/__tests__/codiff-update-notice.test.ts @@ -0,0 +1,51 @@ +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { expect, test } from 'vite-plus/test'; +import { getUpdateNotice } from '../../bin/update-notice.js'; +import { createTemporaryDirectory } from './helpers/resources.ts'; + +const writeState = async ( + configDir: string, + state: { dismissedVersion?: string; lastCheckedAt: string; latestVersion: string }, +) => writeFile(join(configDir, 'update-state.json'), JSON.stringify(state)); + +test('returns null without cached update state', async () => { + await using directory = await createTemporaryDirectory('codiff-notice-'); + expect(getUpdateNotice({ configDir: directory.path, currentVersion: '1.9.2' })).toBeNull(); +}); + +test('announces a newer cached version with both versions', async () => { + await using directory = await createTemporaryDirectory('codiff-notice-'); + await writeState(directory.path, { + lastCheckedAt: '2026-07-28T10:00:00.000Z', + latestVersion: '1.9.3', + }); + + const notice = getUpdateNotice({ configDir: directory.path, currentVersion: '1.9.2' }); + + expect(notice).toContain('1.9.2'); + expect(notice).toContain('1.9.3'); + expect(notice).toContain('codiff update'); +}); + +test('returns null when up to date or ahead', async () => { + await using directory = await createTemporaryDirectory('codiff-notice-'); + await writeState(directory.path, { + lastCheckedAt: '2026-07-28T10:00:00.000Z', + latestVersion: '1.9.3', + }); + + expect(getUpdateNotice({ configDir: directory.path, currentVersion: '1.9.3' })).toBeNull(); + expect(getUpdateNotice({ configDir: directory.path, currentVersion: '2.0.0' })).toBeNull(); +}); + +test('returns null for a dismissed version', async () => { + await using directory = await createTemporaryDirectory('codiff-notice-'); + await writeState(directory.path, { + dismissedVersion: '1.9.3', + lastCheckedAt: '2026-07-28T10:00:00.000Z', + latestVersion: '1.9.3', + }); + + expect(getUpdateNotice({ configDir: directory.path, currentVersion: '1.9.2' })).toBeNull(); +}); diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index 48e4ce0f..38dad1b4 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -4,6 +4,7 @@ import { CaretUpIcon as CaretUp } from '@phosphor-icons/react/CaretUp'; import { ChatCircleIcon as ChatCircle } from '@phosphor-icons/react/ChatCircle'; import { CheckIcon as Check } from '@phosphor-icons/react/Check'; import { CheckCircleIcon as CheckCircle } from '@phosphor-icons/react/CheckCircle'; +import { CircleNotchIcon as CircleNotch } from '@phosphor-icons/react/CircleNotch'; import { PowerIcon as Power } from '@phosphor-icons/react/Power'; import { SealQuestionIcon as SealQuestion } from '@phosphor-icons/react/SealQuestion'; import { WarningOctagonIcon as WarningOctagon } from '@phosphor-icons/react/WarningOctagon'; @@ -24,6 +25,7 @@ import type { RepositoryLoadError, ReviewComment } from '../../lib/app-types.ts' import { buildReviewCommentsMarkdown } from '../../lib/review-comments.ts'; import type { ChangedFile, + CodiffUpdateStatus, PullRequestMergeOptions, PullRequestMergeState, PullRequestReviewEvent, @@ -83,6 +85,69 @@ export function RepositoryChangeBanner({ ); } +export type { CodiffUpdateStatus as UpdateStatus } from '../../types.ts'; + +export function UpdatePill({ + onApply, + status, +}: { + onApply: () => void; + status: CodiffUpdateStatus; +}) { + const { currentVersion, message, phase, strategy, version } = status; + + if (phase === 'idle') { + return null; + } + + const actionable = phase === 'available' || phase === 'error'; + const applyEffect = + strategy === 'squirrel' + ? 'Downloads the update and restarts the app.' + : strategy === 'download' + ? 'Downloads and opens the update. Quit Codiff to finish installing.' + : strategy === 'manual' + ? 'Opens the download page for the new version.' + : 'Downloads the update.'; + const title = + phase === 'available' + ? `Update Codiff${version ? ` v${currentVersion} -> v${version}` : ''}. ${applyEffect}` + : phase === 'updating' + ? version + ? `Updating to Codiff ${version}…` + : 'Updating Codiff…' + : phase === 'installerReady' + ? 'The installer was downloaded and opened. Quit Codiff to finish updating.' + : `${message ?? 'Something went wrong while updating.'} Click to try again.`; + + return ( +
+ +
+ ); +} + export function WalkthroughOutdatedBanner({ onDismiss, reason, diff --git a/core/config/codiff-config.schema.json b/core/config/codiff-config.schema.json index 4fc49206..4a56ca10 100644 --- a/core/config/codiff-config.schema.json +++ b/core/config/codiff-config.schema.json @@ -20,6 +20,11 @@ "default": "codex", "description": "Which agent CLI powers review assistance and walkthroughs. 'codex' uses the OpenAI Codex CLI; 'claude' uses the Claude Code CLI; 'opencode' uses the OpenCode CLI; 'pi' uses the Pi CLI." }, + "checkForUpdates": { + "type": "boolean", + "default": true, + "description": "Check GitHub Releases once a day for a newer Codiff version and show an update notification. Set to false if updates are centrally managed." + }, "claudeModel": { "type": "string", "default": "claude-sonnet-4-6", diff --git a/core/config/types.ts b/core/config/types.ts index 9f31ef7e..afd3934e 100644 --- a/core/config/types.ts +++ b/core/config/types.ts @@ -4,6 +4,7 @@ export type CodiffAgentBackend = 'codex' | 'claude' | 'opencode' | 'pi'; export type CodiffSettings = { agentBackend: CodiffAgentBackend; + checkForUpdates: boolean; claudeModel: string; codeFontFamily: string; codeFontSize: number; diff --git a/core/global.d.ts b/core/global.d.ts index 368661b0..7619d9b1 100644 --- a/core/global.d.ts +++ b/core/global.d.ts @@ -6,6 +6,7 @@ import type { CodiffLaunchOptions, CodiffMarkdownDocument, CodiffPreferences, + CodiffUpdateStatus, DiffImageContentRequest, DiffImageContentResult, DiffSection, @@ -42,12 +43,14 @@ declare module '*.css'; declare global { interface Window { codiff: { + applyUpdate: () => Promise; askReviewAssistant: (request: ReviewAssistantRequest) => Promise; completePlan: (review: PlanReview, status: PlanHandoffStatus) => Promise; createWalkthroughCommit: ( request: WalkthroughCommitRequest, ) => Promise; decreaseCodeFontSize: () => Promise; + dismissUpdate: () => Promise; getAgentSkillStatus: () => Promise; getConfig: () => Promise; getDiffImageContent: (request: DiffImageContentRequest) => Promise; @@ -69,6 +72,7 @@ declare global { getRepositoryHistory: (limit?: number, source?: ReviewSource) => Promise; getRepositoryState: (source?: ReviewSource) => Promise; getTerminalHelperStatus: () => Promise; + getUpdateStatus: () => Promise; increaseCodeFontSize: () => Promise; installAgentSkill: () => Promise; installTerminalHelper: () => Promise; @@ -89,11 +93,13 @@ declare global { onPlanCloseRequested: (callback: () => void) => () => void; onRefreshRequest: (callback: () => void) => () => void; onRepositoryChanged: (callback: (change: { root: string }) => void) => () => void; + onUpdateStatusChanged: (callback: (status: CodiffUpdateStatus) => void) => () => void; onWalkthroughCommitOutput: (callback: (chunk: string) => void) => () => void; onWalkthroughProgress: (callback: (progress: WalkthroughProgressEvent) => void) => () => void; onWindowFullScreenChanged: (callback: (isFullScreen: boolean) => void) => () => void; openConfigFile: () => Promise; openFile: (path: string) => Promise; + openReleasePage: () => Promise; openRepositoryFolder: () => Promise; resetCodeFontSize: () => Promise; resolvePullRequestUrl: (value: string) => Promise; diff --git a/core/types.ts b/core/types.ts index a06d32b4..0f4f9b82 100644 --- a/core/types.ts +++ b/core/types.ts @@ -417,6 +417,7 @@ export type WalkthroughContext = { export type CodiffLaunchOptions = { agentBackend?: 'codex' | 'claude' | 'opencode' | 'pi'; + applyUpdate?: boolean; claudeSessionId?: string; codexSessionId?: string; opencodeSessionId?: string; @@ -762,6 +763,16 @@ export type ReviewPreferences = Pick< 'codeFontFamily' | 'codeFontSize' | 'diffStyle' | 'showWhitespace' | 'theme' | 'wordWrap' >; +export type CodiffUpdatePhase = 'available' | 'error' | 'idle' | 'installerReady' | 'updating'; + +export type CodiffUpdateStatus = { + currentVersion: string; + message?: string; + phase: CodiffUpdatePhase; + strategy?: 'download' | 'manual' | 'squirrel'; + version?: string; +}; + export type PullRequestReviewComment = { anchor?: 'file' | 'line'; body: string; diff --git a/electron/__tests__/command-line.test.ts b/electron/__tests__/command-line.test.ts index 8849b3d9..491ba183 100644 --- a/electron/__tests__/command-line.test.ts +++ b/electron/__tests__/command-line.test.ts @@ -18,6 +18,7 @@ const { getCommandLineLaunchOptions, getCommandLineRepositoryPath, getInitialRep commandLine: ReadonlyArray, fallbackPath?: string, ) => { + applyUpdate?: boolean; codexSessionId?: string; planFile?: string; planResultFile?: string; @@ -71,6 +72,11 @@ const defaultLaunchOptions = { walkthrough: false, }; +test('parses the apply-update launch flag', () => { + expect(getCommandLineLaunchOptions(['codiff', '--apply-update']).applyUpdate).toBe(true); + expect(getCommandLineLaunchOptions(['codiff', '/repo']).applyUpdate).toBeUndefined(); +}); + test('parses the OpenCode agent override', () => { expect( readCommandLine([ diff --git a/electron/__tests__/update-check.test.ts b/electron/__tests__/update-check.test.ts new file mode 100644 index 00000000..75a472e0 --- /dev/null +++ b/electron/__tests__/update-check.test.ts @@ -0,0 +1,261 @@ +import { writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { expect, test } from 'vite-plus/test'; +import { + bindDisposableHttpServer, + createTemporaryDirectory, +} from '../../core/__tests__/helpers/resources.ts'; + +type UpdateState = { + dismissedVersion?: string; + lastCheckedAt: string; + latestVersion: string; +}; + +const require = createRequire(import.meta.url); +const { + extractVersionFromTag, + fetchLatestRelease, + getAvailableUpdate, + isNewerVersion, + LATEST_RELEASE_URL, + readUpdateState, + releasePageUrl, + shouldCheckForUpdates, + UPDATE_CHECK_INTERVAL_MS, + updateFeedUrl, + writeUpdateState, +} = require('../update-check.cjs') as { + extractVersionFromTag: (tag: string) => string | null; + fetchLatestRelease: (url?: string) => Promise<{ + assets: ReadonlyArray<{ name: string; url: string }>; + version: string; + }>; + getAvailableUpdate: ( + state: UpdateState | null, + currentVersion: string, + ) => { version: string } | null; + isNewerVersion: (latest: string, current: string) => boolean; + LATEST_RELEASE_URL: string; + readUpdateState: (configDir?: string) => UpdateState | null; + releasePageUrl: (version: string) => string; + shouldCheckForUpdates: (state: UpdateState | null, now: number) => boolean; + UPDATE_CHECK_INTERVAL_MS: number; + updateFeedUrl: (platform: string, arch: string, version: string) => string; + writeUpdateState: (state: UpdateState, configDir?: string) => void; +}; + +const validState: UpdateState = { + lastCheckedAt: '2026-07-28T10:00:00.000Z', + latestVersion: '1.9.3', +}; + +test('isNewerVersion detects newer patch, minor and major versions', () => { + expect(isNewerVersion('1.9.3', '1.9.2')).toBe(true); + expect(isNewerVersion('1.10.0', '1.9.2')).toBe(true); + expect(isNewerVersion('2.0.0', '1.9.2')).toBe(true); +}); + +test('isNewerVersion compares components numerically, not lexicographically', () => { + expect(isNewerVersion('1.10.0', '1.9.9')).toBe(true); + expect(isNewerVersion('1.9.10', '1.9.9')).toBe(true); +}); + +test('isNewerVersion returns false for equal or older versions', () => { + expect(isNewerVersion('1.9.2', '1.9.2')).toBe(false); + expect(isNewerVersion('1.9.1', '1.9.2')).toBe(false); + expect(isNewerVersion('0.9.9', '1.0.0')).toBe(false); +}); + +test('isNewerVersion treats prerelease or malformed versions as not newer', () => { + expect(isNewerVersion('1.10.0-beta.1', '1.9.2')).toBe(false); + expect(isNewerVersion('1.10', '1.9.2')).toBe(false); + expect(isNewerVersion('not-a-version', '1.9.2')).toBe(false); + expect(isNewerVersion('1.10.0', 'not-a-version')).toBe(false); +}); + +test('extractVersionFromTag strips the leading v', () => { + expect(extractVersionFromTag('v1.9.3')).toBe('1.9.3'); + expect(extractVersionFromTag('1.9.3')).toBe('1.9.3'); +}); + +test('extractVersionFromTag rejects tags that are not plain versions', () => { + expect(extractVersionFromTag('v1.9.3-beta.1')).toBeNull(); + expect(extractVersionFromTag('release-1.9.3')).toBeNull(); + expect(extractVersionFromTag('')).toBeNull(); +}); + +test('readUpdateState returns null when the file does not exist', async () => { + await using directory = await createTemporaryDirectory('codiff-update-'); + expect(readUpdateState(directory.path)).toBeNull(); +}); + +test('readUpdateState returns null for corrupt JSON', async () => { + await using directory = await createTemporaryDirectory('codiff-update-'); + await writeFile(join(directory.path, 'update-state.json'), '{not valid json'); + expect(readUpdateState(directory.path)).toBeNull(); +}); + +test('readUpdateState returns null when fields are missing or invalid', async () => { + await using directory = await createTemporaryDirectory('codiff-update-'); + await writeFile( + join(directory.path, 'update-state.json'), + JSON.stringify({ lastCheckedAt: '2026-07-28T10:00:00.000Z' }), + ); + expect(readUpdateState(directory.path)).toBeNull(); + + await writeFile( + join(directory.path, 'update-state.json'), + JSON.stringify({ ...validState, lastCheckedAt: 'not a date' }), + ); + expect(readUpdateState(directory.path)).toBeNull(); +}); + +test('writeUpdateState round-trips and creates the directory', async () => { + await using directory = await createTemporaryDirectory('codiff-update-'); + const nested = join(directory.path, 'nested'); + const state = { ...validState, dismissedVersion: '1.9.3' }; + writeUpdateState(state, nested); + expect(readUpdateState(nested)).toEqual(state); +}); + +test('readUpdateState drops an invalid dismissedVersion but keeps the rest', async () => { + await using directory = await createTemporaryDirectory('codiff-update-'); + await writeFile( + join(directory.path, 'update-state.json'), + JSON.stringify({ ...validState, dismissedVersion: 42 }), + ); + expect(readUpdateState(directory.path)).toEqual(validState); +}); + +test('shouldCheckForUpdates returns true without previous state', () => { + expect(shouldCheckForUpdates(null, Date.now())).toBe(true); +}); + +test('shouldCheckForUpdates respects the check interval', () => { + const now = Date.parse('2026-07-28T12:00:00.000Z'); + const recent = { ...validState, lastCheckedAt: '2026-07-28T11:00:00.000Z' }; + expect(shouldCheckForUpdates(recent, now)).toBe(false); + + const stale = { ...validState, lastCheckedAt: '2026-07-27T11:00:00.000Z' }; + expect(shouldCheckForUpdates(stale, now)).toBe(true); +}); + +test('shouldCheckForUpdates returns true for an unparseable timestamp', () => { + expect(shouldCheckForUpdates({ ...validState, lastCheckedAt: 'not a date' }, Date.now())).toBe( + true, + ); +}); + +test('UPDATE_CHECK_INTERVAL_MS is twenty hours', () => { + expect(UPDATE_CHECK_INTERVAL_MS).toBe(20 * 60 * 60 * 1000); +}); + +test('getAvailableUpdate returns the newer version', () => { + expect(getAvailableUpdate(validState, '1.9.2')).toEqual({ version: '1.9.3' }); +}); + +test('getAvailableUpdate returns null without state or without a newer version', () => { + expect(getAvailableUpdate(null, '1.9.2')).toBeNull(); + expect(getAvailableUpdate(validState, '1.9.3')).toBeNull(); + expect(getAvailableUpdate(validState, '2.0.0')).toBeNull(); +}); + +test('getAvailableUpdate returns null when the latest version was dismissed', () => { + expect(getAvailableUpdate({ ...validState, dismissedVersion: '1.9.3' }, '1.9.2')).toBeNull(); +}); + +test('getAvailableUpdate resurfaces after a dismissed version is superseded', () => { + const state = { ...validState, dismissedVersion: '1.9.3', latestVersion: '1.9.4' }; + expect(getAvailableUpdate(state, '1.9.2')).toEqual({ version: '1.9.4' }); +}); + +test('LATEST_RELEASE_URL points at the codiff repository', () => { + expect(LATEST_RELEASE_URL).toBe('https://api.github.com/repos/nkzw-tech/codiff/releases/latest'); +}); + +test('updateFeedUrl targets update.electronjs.org for the current install', () => { + expect(updateFeedUrl('darwin', 'arm64', '1.9.2')).toBe( + 'https://update.electronjs.org/nkzw-tech/codiff/darwin-arm64/1.9.2', + ); +}); + +test('releasePageUrl links to the tagged release', () => { + expect(releasePageUrl('1.9.3')).toBe('https://github.com/nkzw-tech/codiff/releases/tag/v1.9.3'); +}); + +test('fetchLatestRelease parses the release and sends a User-Agent', async () => { + let userAgent: string | undefined; + const server = createServer((request, response) => { + userAgent = request.headers['user-agent']; + response.setHeader('content-type', 'application/json'); + response.end( + JSON.stringify({ + assets: [ + { + browser_download_url: + 'https://github.com/nkzw-tech/codiff/releases/download/v1.9.3/Codiff-darwin-arm64-1.9.3.zip', + digest: 'sha256:4db4acfef44780957e2801700008c94399f57d84f7971a948a3e2851c1366175', + name: 'Codiff-darwin-arm64-1.9.3.zip', + }, + ], + tag_name: 'v1.9.3', + }), + ); + }); + await using _ = await bindDisposableHttpServer(server); + const port = (server.address() as { port: number }).port; + + const release = await fetchLatestRelease(`http://127.0.0.1:${port}/`); + + expect(release.version).toBe('1.9.3'); + expect(release.assets).toEqual([ + { + digest: 'sha256:4db4acfef44780957e2801700008c94399f57d84f7971a948a3e2851c1366175', + name: 'Codiff-darwin-arm64-1.9.3.zip', + url: 'https://github.com/nkzw-tech/codiff/releases/download/v1.9.3/Codiff-darwin-arm64-1.9.3.zip', + }, + ]); + expect(userAgent).toBeTruthy(); +}); + +test('fetchLatestRelease rejects on a non-2xx response', async () => { + const server = createServer((_request, response) => { + response.statusCode = 500; + response.end('nope'); + }); + await using _ = await bindDisposableHttpServer(server); + const port = (server.address() as { port: number }).port; + + await expect(fetchLatestRelease(`http://127.0.0.1:${port}/`)).rejects.toThrow(); +}); + +test('fetchLatestRelease rejects when the tag is missing or not a version', async () => { + let tagName = 'v2.0.0-beta.1'; + const server = createServer((_request, response) => { + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ assets: [], tag_name: tagName })); + }); + await using _ = await bindDisposableHttpServer(server); + const port = (server.address() as { port: number }).port; + + await expect(fetchLatestRelease(`http://127.0.0.1:${port}/`)).rejects.toThrow(); + + tagName = ''; + await expect(fetchLatestRelease(`http://127.0.0.1:${port}/`)).rejects.toThrow(); +}); + +test('fetchLatestRelease tolerates releases without assets', async () => { + const server = createServer((_request, response) => { + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ tag_name: 'v1.9.3' })); + }); + await using _ = await bindDisposableHttpServer(server); + const port = (server.address() as { port: number }).port; + + const release = await fetchLatestRelease(`http://127.0.0.1:${port}/`); + expect(release.version).toBe('1.9.3'); + expect(release.assets).toEqual([]); +}); diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts new file mode 100644 index 00000000..b00ca477 --- /dev/null +++ b/electron/__tests__/updater.test.ts @@ -0,0 +1,1957 @@ +import { createHash } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import { existsSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { expect, test } from 'vite-plus/test'; +import { + bindDisposableHttpServer, + createTemporaryDirectory, +} from '../../core/__tests__/helpers/resources.ts'; + +type UpdateStatus = { + currentVersion: string; + message?: string; + phase: 'available' | 'error' | 'idle' | 'installerReady' | 'updating'; + strategy?: 'download' | 'manual' | 'squirrel'; + version?: string; +}; + +type Updater = { + applyLatest: () => Promise; + applyUpdate: () => Promise; + checkForUpdates: (options?: { force?: boolean }) => Promise; + dismissUpdate: () => UpdateStatus; + getStatus: () => UpdateStatus; +}; + +type ReleaseAsset = { digest?: string; name: string; url: string }; + +const require = createRequire(import.meta.url); +const { createUpdater, pickReleaseAsset, resolveUpdateStrategy } = require('../updater.cjs') as { + createUpdater: (options: { + arch: string; + autoUpdater?: FakeAutoUpdater; + configDir: string; + currentVersion: string; + downloadDirectory?: string; + isPackaged: boolean; + linuxFlavor?: 'deb' | 'rpm' | null; + log?: (message: string) => void; + onStatusChange?: (status: UpdateStatus) => void; + openExternal?: (url: string) => Promise; + openPath?: (path: string) => Promise; + platform: string; + releaseUrl?: string; + strategy: 'download' | 'manual' | 'squirrel'; + }) => Updater; + pickReleaseAsset: ( + assets: ReadonlyArray, + options: { arch: string; linuxFlavor?: 'deb' | 'rpm' | null; platform: string }, + ) => ReleaseAsset | null; + resolveUpdateStrategy: (options: { + hasSquirrelUpdateExe: boolean; + platform: string; + }) => 'download' | 'manual' | 'squirrel'; +}; + +class FakeAutoUpdater extends EventEmitter { + feedURL: { url: string } | null = null; + checkForUpdatesCalls = 0; + quitAndInstallCalls = 0; + + setFeedURL(options: { url: string }) { + this.feedURL = options; + } + + checkForUpdates() { + this.checkForUpdatesCalls++; + } + + quitAndInstall() { + this.quitAndInstallCalls++; + } +} + +const releaseJson = (version: string, assets: ReadonlyArray> = []) => + JSON.stringify({ + assets, + tag_name: `v${version}`, + }); + +const startReleaseServer = async (handler: Parameters[1]) => { + const server = createServer(handler); + const disposable = await bindDisposableHttpServer(server); + const port = (server.address() as { port: number }).port; + return { disposable, origin: `http://127.0.0.1:${port}` }; +}; + +const writeState = async ( + configDir: string, + state: { dismissedVersion?: string; lastCheckedAt: string; latestVersion: string }, +) => writeFile(join(configDir, 'update-state.json'), JSON.stringify(state)); + +const recentCheck = () => new Date().toISOString(); + +const sha256 = (data: string) => `sha256:${createHash('sha256').update(data).digest('hex')}`; + +const waitFor = async (condition: () => boolean) => { + const deadline = Date.now() + 2000; + while (!condition()) { + if (Date.now() > deadline) { + throw new Error('Timed out waiting for condition.'); + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 5)); + } +}; + +test('resolveUpdateStrategy uses Squirrel on macOS and Squirrel-installed Windows', () => { + expect(resolveUpdateStrategy({ hasSquirrelUpdateExe: false, platform: 'darwin' })).toBe( + 'squirrel', + ); + expect(resolveUpdateStrategy({ hasSquirrelUpdateExe: true, platform: 'win32' })).toBe('squirrel'); + expect(resolveUpdateStrategy({ hasSquirrelUpdateExe: false, platform: 'win32' })).toBe('manual'); + expect(resolveUpdateStrategy({ hasSquirrelUpdateExe: false, platform: 'linux' })).toBe( + 'download', + ); +}); + +test('pickReleaseAsset matches the platform-specific artifact', () => { + const assets = [ + { name: 'Codiff-darwin-arm64-1.9.3.zip', url: 'https://example.com/mac.zip' }, + { name: 'Codiff-win32-x64-1.9.3.zip', url: 'https://example.com/win.zip' }, + { name: 'codiff_1.9.3_amd64.deb', url: 'https://example.com/codiff.deb' }, + { name: 'codiff-1.9.3-1.x86_64.rpm', url: 'https://example.com/codiff.rpm' }, + ]; + + expect(pickReleaseAsset(assets, { arch: 'arm64', platform: 'darwin' })?.name).toBe( + 'Codiff-darwin-arm64-1.9.3.zip', + ); + expect(pickReleaseAsset(assets, { arch: 'x64', platform: 'win32' })?.name).toBe( + 'Codiff-win32-x64-1.9.3.zip', + ); + expect( + pickReleaseAsset(assets, { arch: 'x64', linuxFlavor: 'deb', platform: 'linux' })?.name, + ).toBe('codiff_1.9.3_amd64.deb'); + expect( + pickReleaseAsset(assets, { arch: 'x64', linuxFlavor: 'rpm', platform: 'linux' })?.name, + ).toBe('codiff-1.9.3-1.x86_64.rpm'); + expect(pickReleaseAsset(assets, { arch: 'x64', platform: 'freebsd' })).toBeNull(); +}); + +test('pickReleaseAsset matches the Linux installer for the running architecture', () => { + const assets = [ + { name: 'codiff_1.9.3_amd64.deb', url: 'https://example.com/amd64.deb' }, + { name: 'codiff_1.9.3_arm64.deb', url: 'https://example.com/arm64.deb' }, + { name: 'codiff-1.9.3-1.x86_64.rpm', url: 'https://example.com/x86_64.rpm' }, + { name: 'codiff-1.9.3-1.aarch64.rpm', url: 'https://example.com/aarch64.rpm' }, + ]; + + expect( + pickReleaseAsset(assets, { arch: 'x64', linuxFlavor: 'deb', platform: 'linux' })?.name, + ).toBe('codiff_1.9.3_amd64.deb'); + expect( + pickReleaseAsset(assets, { arch: 'arm64', linuxFlavor: 'deb', platform: 'linux' })?.name, + ).toBe('codiff_1.9.3_arm64.deb'); + expect( + pickReleaseAsset(assets, { arch: 'x64', linuxFlavor: 'rpm', platform: 'linux' })?.name, + ).toBe('codiff-1.9.3-1.x86_64.rpm'); + expect( + pickReleaseAsset(assets, { arch: 'arm64', linuxFlavor: 'rpm', platform: 'linux' })?.name, + ).toBe('codiff-1.9.3-1.aarch64.rpm'); +}); + +test('pickReleaseAsset refuses a Linux installer built for another architecture', () => { + const amd64Only = [{ name: 'codiff_1.9.3_amd64.deb', url: 'https://example.com/amd64.deb' }]; + + expect( + pickReleaseAsset(amd64Only, { arch: 'arm64', linuxFlavor: 'deb', platform: 'linux' }), + ).toBeNull(); + expect( + pickReleaseAsset([{ name: 'codiff_1.9.3.deb', url: 'https://example.com/codiff.deb' }], { + arch: 'arm64', + linuxFlavor: 'deb', + platform: 'linux', + })?.name, + ).toBe('codiff_1.9.3.deb'); +}); + +test('starts from the cached state without hitting the network', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const updater = createUpdater({ + arch: 'arm64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + strategy: 'squirrel', + }); + + expect(updater.getStatus()).toEqual({ + currentVersion: '1.9.2', + phase: 'available', + strategy: 'squirrel', + version: '1.9.3', + }); +}); + +test('reports the strategy that will apply the update', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const squirrelUpdater = createUpdater({ + arch: 'arm64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + strategy: 'squirrel', + }); + const downloadUpdater = createUpdater({ + arch: 'x64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + linuxFlavor: 'deb', + platform: 'linux', + strategy: 'download', + }); + + expect(squirrelUpdater.getStatus().strategy).toBe('squirrel'); + expect(downloadUpdater.getStatus().strategy).toBe('download'); +}); + +test('checkForUpdates fetches, persists state and reports an available update', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson('1.9.3')); + }); + + const notifications: Array = []; + const updater = createUpdater({ + arch: 'arm64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + onStatusChange: (status) => notifications.push(status), + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const status = await updater.checkForUpdates(); + + expect(status).toEqual({ + currentVersion: '1.9.2', + phase: 'available', + strategy: 'squirrel', + version: '1.9.3', + }); + expect(notifications).toEqual([status]); + + const persisted = JSON.parse( + await readFile(join(directory.path, 'update-state.json'), 'utf8'), + ) as { latestVersion: string }; + expect(persisted.latestVersion).toBe('1.9.3'); +}); + +test('checkForUpdates stays idle when already up to date', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson('1.9.2')); + }); + + const updater = createUpdater({ + arch: 'arm64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + expect(await updater.checkForUpdates()).toEqual({ + currentVersion: '1.9.2', + phase: 'idle', + strategy: 'squirrel', + }); +}); + +test('checkForUpdates honors the throttle and force bypasses it', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + let requests = 0; + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + requests++; + response.setHeader('content-type', 'application/json'); + response.end(releaseJson('1.9.4')); + }); + + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const updater = createUpdater({ + arch: 'arm64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const throttled = await updater.checkForUpdates(); + expect(requests).toBe(0); + expect(throttled).toEqual({ + currentVersion: '1.9.2', + phase: 'available', + strategy: 'squirrel', + version: '1.9.3', + }); + + const forced = await updater.checkForUpdates({ force: true }); + expect(requests).toBe(1); + expect(forced).toEqual({ + currentVersion: '1.9.2', + phase: 'available', + strategy: 'squirrel', + version: '1.9.4', + }); +}); + +test('checkForUpdates swallows network failures and keeps the cached state', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + response.statusCode = 500; + response.end('nope'); + }); + + const previous = { + lastCheckedAt: '2026-01-01T00:00:00.000Z', + latestVersion: '1.9.3', + }; + await writeState(directory.path, previous); + + const log: Array = []; + const updater = createUpdater({ + arch: 'arm64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + log: (message) => log.push(message), + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const status = await updater.checkForUpdates(); + + expect(status).toEqual({ + currentVersion: '1.9.2', + phase: 'available', + strategy: 'squirrel', + version: '1.9.3', + }); + expect(log.length).toBe(1); + + const persisted = JSON.parse( + await readFile(join(directory.path, 'update-state.json'), 'utf8'), + ) as { lastCheckedAt: string }; + expect(persisted.lastCheckedAt).toBe(previous.lastCheckedAt); +}); + +test('checkForUpdates rejects on failure only when forced', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + response.statusCode = 500; + response.end('nope'); + }); + + const updater = createUpdater({ + arch: 'arm64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + log: () => {}, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + await expect(updater.checkForUpdates()).resolves.toEqual({ + currentVersion: '1.9.2', + phase: 'idle', + strategy: 'squirrel', + }); + await expect(updater.checkForUpdates({ force: true })).rejects.toThrow(); +}); + +test('checkForUpdates does nothing for unpackaged builds', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + let requests = 0; + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + requests++; + response.end(releaseJson('9.9.9')); + }); + + const updater = createUpdater({ + arch: 'arm64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: false, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + expect(await updater.checkForUpdates()).toEqual({ + currentVersion: '1.9.2', + phase: 'idle', + strategy: 'squirrel', + }); + expect(requests).toBe(0); +}); + +test('dismissUpdate persists the dismissal and hides the update', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const notifications: Array = []; + const updater = createUpdater({ + arch: 'arm64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + onStatusChange: (status) => notifications.push(status), + platform: 'darwin', + strategy: 'squirrel', + }); + + expect(updater.dismissUpdate()).toEqual({ + currentVersion: '1.9.2', + phase: 'idle', + strategy: 'squirrel', + }); + expect(notifications).toEqual([{ currentVersion: '1.9.2', phase: 'idle', strategy: 'squirrel' }]); + + const persisted = JSON.parse( + await readFile(join(directory.path, 'update-state.json'), 'utf8'), + ) as { dismissedVersion: string }; + expect(persisted.dismissedVersion).toBe('1.9.3'); +}); + +test('applyUpdate drives Squirrel through download and restart', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const autoUpdater = new FakeAutoUpdater(); + const notifications: Array = []; + const updater = createUpdater({ + arch: 'arm64', + autoUpdater, + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + onStatusChange: (status) => notifications.push(status), + platform: 'darwin', + strategy: 'squirrel', + }); + + const status = await updater.applyUpdate(); + + expect(status).toEqual({ + currentVersion: '1.9.2', + phase: 'updating', + strategy: 'squirrel', + version: '1.9.3', + }); + expect(autoUpdater.feedURL).toEqual({ + url: 'https://update.electronjs.org/nkzw-tech/codiff/darwin-arm64/1.9.2', + }); + expect(autoUpdater.checkForUpdatesCalls).toBe(1); + + autoUpdater.emit('update-downloaded'); + expect(autoUpdater.quitAndInstallCalls).toBe(1); +}); + +test('applyUpdate reports Squirrel errors and offers retry', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const autoUpdater = new FakeAutoUpdater(); + const updater = createUpdater({ + arch: 'arm64', + autoUpdater, + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + strategy: 'squirrel', + }); + + await updater.applyUpdate(); + autoUpdater.emit('error', new Error('feed unreachable')); + + const status = updater.getStatus(); + expect(status.phase).toBe('error'); + expect(status.version).toBe('1.9.3'); + expect(status.message).toBeTruthy(); + expect(autoUpdater.quitAndInstallCalls).toBe(0); +}); + +test('applyUpdate reports an error when Squirrel has no update yet', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const autoUpdater = new FakeAutoUpdater(); + const updater = createUpdater({ + arch: 'arm64', + autoUpdater, + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + strategy: 'squirrel', + }); + + await updater.applyUpdate(); + autoUpdater.emit('update-not-available'); + + expect(updater.getStatus().phase).toBe('error'); +}); + +test('applyUpdate downloads and opens the installer for download installs', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await using downloads = await createTemporaryDirectory('codiff-downloads-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const { disposable: _server, origin } = await startReleaseServer((request, response) => { + if (request.url === '/asset.deb') { + response.setHeader('content-type', 'application/octet-stream'); + response.end('deb-bytes'); + return; + } + + response.setHeader('content-type', 'application/json'); + response.end( + releaseJson('1.9.3', [ + { + browser_download_url: `${origin}/asset.deb`, + digest: sha256('deb-bytes'), + name: 'codiff_1.9.3_amd64.deb', + }, + ]), + ); + }); + + const opened: Array = []; + const updater = createUpdater({ + arch: 'x64', + configDir: directory.path, + currentVersion: '1.9.2', + downloadDirectory: downloads.path, + isPackaged: true, + linuxFlavor: 'deb', + openPath: async (path) => { + opened.push(path); + return ''; + }, + platform: 'linux', + releaseUrl: `${origin}/`, + strategy: 'download', + }); + + const status = await updater.applyUpdate(); + + expect(status).toEqual({ + currentVersion: '1.9.2', + phase: 'installerReady', + strategy: 'download', + version: '1.9.3', + }); + expect(opened).toEqual([join(downloads.path, 'codiff_1.9.3_amd64.deb')]); + expect(await readFile(join(downloads.path, 'codiff_1.9.3_amd64.deb'), 'utf8')).toBe('deb-bytes'); +}); + +test('applyUpdate opens the release page for manual installs', async () => { + // A plain Windows ZIP install has no Squirrel and opening a downloaded ZIP + // would not replace the app, so the update hands off to the release page. + await using directory = await createTemporaryDirectory('codiff-updater-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const openedUrls: Array = []; + const updater = createUpdater({ + arch: 'x64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + openExternal: async (url) => { + openedUrls.push(url); + }, + platform: 'win32', + strategy: 'manual', + }); + + const status = await updater.applyUpdate(); + + expect(openedUrls).toEqual(['https://github.com/nkzw-tech/codiff/releases/tag/v1.9.3']); + expect(status).toEqual({ + currentVersion: '1.9.2', + phase: 'available', + strategy: 'manual', + version: '1.9.3', + }); +}); + +test('applyUpdate reports an error when the release page cannot be opened', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const updater = createUpdater({ + arch: 'x64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + openExternal: async () => { + throw new Error('No browser is available.'); + }, + platform: 'win32', + strategy: 'manual', + }); + + const status = await updater.applyUpdate(); + + expect(status.phase).toBe('error'); + expect(status.message).toContain('No browser is available.'); +}); + +test('applyUpdate reports an error when no matching asset exists', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await using downloads = await createTemporaryDirectory('codiff-downloads-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson('1.9.3')); + }); + + const updater = createUpdater({ + arch: 'x64', + configDir: directory.path, + currentVersion: '1.9.2', + downloadDirectory: downloads.path, + isPackaged: true, + openPath: async () => '', + platform: 'freebsd', + releaseUrl: `${origin}/`, + strategy: 'download', + }); + + expect((await updater.applyUpdate()).phase).toBe('error'); +}); + +test('concurrent applyUpdate calls download the installer only once', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await using downloads = await createTemporaryDirectory('codiff-downloads-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + let assetRequests = 0; + const { disposable: _server, origin } = await startReleaseServer((request, response) => { + if (request.url === '/asset.deb') { + assetRequests++; + response.end('deb-bytes'); + return; + } + + response.setHeader('content-type', 'application/json'); + response.end( + releaseJson('1.9.3', [ + { + browser_download_url: `${origin}/asset.deb`, + digest: sha256('deb-bytes'), + name: 'codiff_1.9.3_amd64.deb', + }, + ]), + ); + }); + + const opened: Array = []; + const updater = createUpdater({ + arch: 'x64', + configDir: directory.path, + currentVersion: '1.9.2', + downloadDirectory: downloads.path, + isPackaged: true, + linuxFlavor: 'deb', + openPath: async (path) => { + opened.push(path); + return ''; + }, + platform: 'linux', + releaseUrl: `${origin}/`, + strategy: 'download', + }); + + await Promise.all([updater.applyUpdate(), updater.applyUpdate()]); + + expect(assetRequests).toBe(1); + expect(opened.length).toBe(1); +}); + +test('concurrent manual applies open the release page only once', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + let resolveOpen = () => {}; + const openedUrls: Array = []; + const updater = createUpdater({ + arch: 'x64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + openExternal: (url) => { + openedUrls.push(url); + return new Promise((resolveOpened) => { + resolveOpen = resolveOpened; + }); + }, + platform: 'win32', + strategy: 'manual', + }); + + const first = updater.applyUpdate(); + const second = updater.applyUpdate(); + resolveOpen(); + + await Promise.all([first, second]); + + expect(openedUrls.length).toBe(1); + expect(updater.getStatus().phase).toBe('available'); +}); + +test('applyLatest supersedes a pending manual hand-off', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson('1.9.4')); + }); + + const openedUrls: Array = []; + let rejectFirstOpen: (error: Error) => void = () => {}; + let resolveSecondOpen = () => {}; + const updater = createUpdater({ + arch: 'x64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + openExternal: (url) => { + openedUrls.push(url); + return new Promise((resolveOpened, rejectOpened) => { + if (openedUrls.length === 1) { + rejectFirstOpen = rejectOpened; + } else { + resolveSecondOpen = resolveOpened; + } + }); + }, + platform: 'win32', + releaseUrl: `${origin}/`, + strategy: 'manual', + }); + + const pending = updater.applyUpdate(); + const latest = updater.applyLatest(); + + // The forced check discovers 1.9.4 while the 1.9.3 hand-off is still + // opening; the newer request must supersede it, not become a no-op. + await waitFor(() => updater.getStatus().version === '1.9.4'); + rejectFirstOpen(new Error('No browser is available.')); + await waitFor(() => openedUrls.length === 2); + resolveSecondOpen(); + + const [first, second] = await Promise.all([pending, latest]); + + expect(openedUrls).toEqual([ + 'https://github.com/nkzw-tech/codiff/releases/tag/v1.9.3', + 'https://github.com/nkzw-tech/codiff/releases/tag/v1.9.4', + ]); + expect(first.phase).toBe('available'); + expect(second).toEqual({ + currentVersion: '1.9.2', + phase: 'available', + strategy: 'manual', + version: '1.9.4', + }); + expect(updater.getStatus().phase).toBe('available'); + expect(updater.getStatus().version).toBe('1.9.4'); +}); + +test('a dismissal during a queued manual hand-off wins', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson('1.9.4')); + }); + + const openedUrls: Array = []; + let rejectFirstOpen: (error: Error) => void = () => {}; + const updater = createUpdater({ + arch: 'x64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + openExternal: (url) => { + openedUrls.push(url); + return openedUrls.length === 1 + ? new Promise((_resolveOpened, rejectOpened) => { + rejectFirstOpen = rejectOpened; + }) + : Promise.reject(new Error('No browser is available.')); + }, + platform: 'win32', + releaseUrl: `${origin}/`, + strategy: 'manual', + }); + + const pending = updater.applyUpdate(); + const latest = updater.applyLatest(); + await waitFor(() => updater.getStatus().version === '1.9.4'); + + // The dismissal arrives while the 1.9.4 hand-off is still queued behind the + // hanging 1.9.3 open; it must win over both once they settle. + updater.dismissUpdate(); + rejectFirstOpen(new Error('No browser is available.')); + + const [first, second] = await Promise.all([pending, latest]); + + expect(openedUrls).toEqual(['https://github.com/nkzw-tech/codiff/releases/tag/v1.9.3']); + expect(first.phase).toBe('idle'); + expect(second.phase).toBe('idle'); + expect(updater.getStatus().phase).toBe('idle'); +}); + +test('a fresh request after a dismissal does not share a dead hand-off', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson('1.9.4')); + }); + + const openedUrls: Array = []; + let rejectFirstOpen: (error: Error) => void = () => {}; + let resolveSecondOpen = () => {}; + const updater = createUpdater({ + arch: 'x64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + openExternal: (url) => { + openedUrls.push(url); + return new Promise((resolveOpened, rejectOpened) => { + if (openedUrls.length === 1) { + rejectFirstOpen = rejectOpened; + } else { + resolveSecondOpen = resolveOpened; + } + }); + }, + platform: 'win32', + releaseUrl: `${origin}/`, + strategy: 'manual', + }); + + const pending = updater.applyUpdate(); + const invalidated = updater.applyLatest(); + await waitFor(() => updater.getStatus().version === '1.9.4'); + updater.dismissUpdate(); + + // The dismissal killed the queued 1.9.4 hand-off; a fresh request for the + // same version must queue its own hand-off instead of sharing the dead one. + const fresh = updater.applyLatest(); + await waitFor(() => updater.getStatus().phase === 'available'); + rejectFirstOpen(new Error('No browser is available.')); + await waitFor(() => openedUrls.length === 2); + resolveSecondOpen(); + + const [, , freshStatus] = await Promise.all([pending, invalidated, fresh]); + + expect(openedUrls).toEqual([ + 'https://github.com/nkzw-tech/codiff/releases/tag/v1.9.3', + 'https://github.com/nkzw-tech/codiff/releases/tag/v1.9.4', + ]); + expect(freshStatus).toEqual({ + currentVersion: '1.9.2', + phase: 'available', + strategy: 'manual', + version: '1.9.4', + }); + expect(updater.getStatus().phase).toBe('available'); + expect(updater.getStatus().version).toBe('1.9.4'); +}); + +test('a dismissal wins over a pending manual failure', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + let rejectOpen: (error: Error) => void = () => {}; + const updater = createUpdater({ + arch: 'x64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + openExternal: () => + new Promise((_resolveOpened, rejectOpened) => { + rejectOpen = rejectOpened; + }), + platform: 'win32', + strategy: 'manual', + }); + + const pending = updater.applyUpdate(); + updater.dismissUpdate(); + rejectOpen(new Error('No browser is available.')); + + const result = await pending; + + expect(result.phase).toBe('idle'); + expect(updater.getStatus().phase).toBe('idle'); +}); + +test('a manual retry clears the error once the release page opens', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + let attempts = 0; + const updater = createUpdater({ + arch: 'x64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + openExternal: async () => { + if (++attempts === 1) { + throw new Error('No browser is available.'); + } + }, + platform: 'win32', + strategy: 'manual', + }); + + expect((await updater.applyUpdate()).phase).toBe('error'); + + const status = await updater.applyUpdate(); + + expect(status.phase).toBe('available'); + expect(status.version).toBe('1.9.3'); +}); + +test('applyUpdate refuses an installer that fails its integrity check', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await using downloads = await createTemporaryDirectory('codiff-downloads-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const { disposable: _server, origin } = await startReleaseServer((request, response) => { + if (request.url === '/asset.deb') { + response.end('tampered-bytes'); + return; + } + + response.setHeader('content-type', 'application/json'); + response.end( + releaseJson('1.9.3', [ + { + browser_download_url: `${origin}/asset.deb`, + digest: sha256('deb-bytes'), + name: 'codiff_1.9.3_amd64.deb', + }, + ]), + ); + }); + + const opened: Array = []; + const updater = createUpdater({ + arch: 'x64', + configDir: directory.path, + currentVersion: '1.9.2', + downloadDirectory: downloads.path, + isPackaged: true, + linuxFlavor: 'deb', + openPath: async (path) => { + opened.push(path); + return ''; + }, + platform: 'linux', + releaseUrl: `${origin}/`, + strategy: 'download', + }); + + const status = await updater.applyUpdate(); + + expect(status.phase).toBe('error'); + expect(status.message).toContain('integrity check'); + expect(opened).toEqual([]); + expect(existsSync(join(downloads.path, 'codiff_1.9.3_amd64.deb'))).toBe(false); +}); + +test('applyUpdate refuses an installer without a published checksum', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await using downloads = await createTemporaryDirectory('codiff-downloads-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const { disposable: _server, origin } = await startReleaseServer((request, response) => { + if (request.url === '/asset.deb') { + response.end('deb-bytes'); + return; + } + + response.setHeader('content-type', 'application/json'); + response.end( + releaseJson('1.9.3', [ + { browser_download_url: `${origin}/asset.deb`, name: 'codiff_1.9.3_amd64.deb' }, + ]), + ); + }); + + const opened: Array = []; + const updater = createUpdater({ + arch: 'x64', + configDir: directory.path, + currentVersion: '1.9.2', + downloadDirectory: downloads.path, + isPackaged: true, + linuxFlavor: 'deb', + openPath: async (path) => { + opened.push(path); + return ''; + }, + platform: 'linux', + releaseUrl: `${origin}/`, + strategy: 'download', + }); + + const status = await updater.applyUpdate(); + + expect(status.phase).toBe('error'); + expect(status.message).toContain('checksum'); + expect(opened).toEqual([]); +}); + +test('applyUpdate reports an error when the installer cannot be opened', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await using downloads = await createTemporaryDirectory('codiff-downloads-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const { disposable: _server, origin } = await startReleaseServer((request, response) => { + if (request.url === '/asset.deb') { + response.end('deb-bytes'); + return; + } + + response.setHeader('content-type', 'application/json'); + response.end( + releaseJson('1.9.3', [ + { + browser_download_url: `${origin}/asset.deb`, + digest: sha256('deb-bytes'), + name: 'codiff_1.9.3_amd64.deb', + }, + ]), + ); + }); + + const updater = createUpdater({ + arch: 'x64', + configDir: directory.path, + currentVersion: '1.9.2', + downloadDirectory: downloads.path, + isPackaged: true, + linuxFlavor: 'deb', + openPath: async () => 'No application found to open the file.', + platform: 'linux', + releaseUrl: `${origin}/`, + strategy: 'download', + }); + + const status = await updater.applyUpdate(); + + expect(status.phase).toBe('error'); + expect(status.message).toContain('No application found'); +}); + +test('a dismissal during an in-flight check is not erased', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + let releaseResponse = () => {}; + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + releaseResponse = () => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson('1.9.3')); + }; + }); + + await writeState(directory.path, { + lastCheckedAt: '2026-01-01T00:00:00.000Z', + latestVersion: '1.9.3', + }); + + const updater = createUpdater({ + arch: 'arm64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const check = updater.checkForUpdates(); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)); + updater.dismissUpdate(); + releaseResponse(); + const status = await check; + + expect(status).toEqual({ currentVersion: '1.9.2', phase: 'idle', strategy: 'squirrel' }); + + const persisted = JSON.parse( + await readFile(join(directory.path, 'update-state.json'), 'utf8'), + ) as { dismissedVersion?: string }; + expect(persisted.dismissedVersion).toBe('1.9.3'); +}); + +test('a forced check clears the dismissal and resurfaces the update', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson('1.9.3')); + }); + + await writeState(directory.path, { + dismissedVersion: '1.9.3', + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const updater = createUpdater({ + arch: 'arm64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const status = await updater.checkForUpdates({ force: true }); + + expect(status).toEqual({ + currentVersion: '1.9.2', + phase: 'available', + strategy: 'squirrel', + version: '1.9.3', + }); + + const persisted = JSON.parse( + await readFile(join(directory.path, 'update-state.json'), 'utf8'), + ) as { dismissedVersion?: string }; + expect(persisted.dismissedVersion).toBeUndefined(); +}); + +test('concurrent checks run one at a time and the newest result wins', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const pending: Array<(version: string) => void> = []; + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + pending.push((version) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson(version)); + }); + }); + + await writeState(directory.path, { + lastCheckedAt: '2026-01-01T00:00:00.000Z', + latestVersion: '1.9.2', + }); + + const updater = createUpdater({ + arch: 'arm64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const scheduled = updater.checkForUpdates(); + await waitFor(() => pending.length === 1); + const forced = updater.checkForUpdates({ force: true }); + + await new Promise((resolveDelay) => setTimeout(resolveDelay, 50)); + expect(pending.length).toBe(1); + + pending[0]('1.9.3'); + expect(await scheduled).toEqual({ + currentVersion: '1.9.2', + phase: 'available', + strategy: 'squirrel', + version: '1.9.3', + }); + + await waitFor(() => pending.length === 2); + pending[1]('1.9.4'); + expect(await forced).toEqual({ + currentVersion: '1.9.2', + phase: 'available', + strategy: 'squirrel', + version: '1.9.4', + }); + + const persisted = JSON.parse( + await readFile(join(directory.path, 'update-state.json'), 'utf8'), + ) as { latestVersion: string }; + expect(persisted.latestVersion).toBe('1.9.4'); +}); + +test('a forced check keeps a dismissal made while it was in flight', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const pending: Array<(version: string) => void> = []; + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + pending.push((version) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson(version)); + }); + }); + + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const updater = createUpdater({ + arch: 'arm64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const forced = updater.checkForUpdates({ force: true }); + await waitFor(() => pending.length === 1); + updater.dismissUpdate(); + pending[0]('1.9.4'); + await forced; + + const persisted = JSON.parse( + await readFile(join(directory.path, 'update-state.json'), 'utf8'), + ) as { dismissedVersion?: string }; + expect(persisted.dismissedVersion).toBe('1.9.3'); +}); + +test('a check completion does not cancel an active Squirrel update', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const pending: Array<(version: string) => void> = []; + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + pending.push((version) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson(version)); + }); + }); + + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const autoUpdater = new FakeAutoUpdater(); + const updater = createUpdater({ + arch: 'arm64', + autoUpdater, + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const check = updater.checkForUpdates({ force: true }); + await waitFor(() => pending.length === 1); + await updater.applyUpdate(); + pending[0]('1.9.3'); + await check; + + expect(updater.getStatus().phase).toBe('updating'); + + autoUpdater.emit('update-downloaded'); + expect(autoUpdater.quitAndInstallCalls).toBe(1); +}); + +test('a forced check failure belongs only to its own caller', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const pending: Array<(respond: { status?: number; version?: string }) => void> = []; + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + pending.push(({ status: statusCode, version }) => { + if (statusCode) { + response.statusCode = statusCode; + response.end('nope'); + return; + } + response.setHeader('content-type', 'application/json'); + response.end(releaseJson(version ?? '0.0.1')); + }); + }); + + await writeState(directory.path, { + lastCheckedAt: '2026-01-01T00:00:00.000Z', + latestVersion: '1.9.2', + }); + + const log: Array = []; + const updater = createUpdater({ + arch: 'arm64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + log: (message) => log.push(message), + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const first = updater.checkForUpdates({ force: true }); + await waitFor(() => pending.length === 1); + const second = updater.checkForUpdates({ force: true }); + + pending[0]({ status: 500 }); + await expect(first).rejects.toThrow(); + + await waitFor(() => pending.length === 2); + pending[1]({ version: '1.9.4' }); + await expect(second).resolves.toEqual({ + currentVersion: '1.9.2', + phase: 'available', + strategy: 'squirrel', + version: '1.9.4', + }); + expect(log.length).toBe(1); +}); + +test('a queued check cannot discard a successful forced result', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const pending: Array<(version: string) => void> = []; + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + pending.push((version) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson(version)); + }); + }); + + await writeState(directory.path, { + lastCheckedAt: '2026-01-01T00:00:00.000Z', + latestVersion: '1.9.2', + }); + + const updater = createUpdater({ + arch: 'arm64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const forced = updater.checkForUpdates({ force: true }); + await waitFor(() => pending.length === 1); + const scheduled = updater.checkForUpdates(); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)); + + pending[0]('1.9.4'); + + expect(await forced).toEqual({ + currentVersion: '1.9.2', + phase: 'available', + strategy: 'squirrel', + version: '1.9.4', + }); + expect(await scheduled).toEqual({ + currentVersion: '1.9.2', + phase: 'available', + strategy: 'squirrel', + version: '1.9.4', + }); + expect(pending.length).toBe(1); + + const persisted = JSON.parse( + await readFile(join(directory.path, 'update-state.json'), 'utf8'), + ) as { latestVersion: string }; + expect(persisted.latestVersion).toBe('1.9.4'); +}); + +test('a check completion does not erase an apply failure', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const pending: Array<(version: string) => void> = []; + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + pending.push((version) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson(version)); + }); + }); + + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const autoUpdater = new FakeAutoUpdater(); + const updater = createUpdater({ + arch: 'arm64', + autoUpdater, + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const check = updater.checkForUpdates({ force: true }); + await waitFor(() => pending.length === 1); + await updater.applyUpdate(); + autoUpdater.emit('error', new Error('Squirrel download failed')); + pending[0]('1.9.3'); + const status = await check; + + expect(status.phase).toBe('error'); + expect(status.message).toContain('Squirrel download failed'); + expect(updater.getStatus().phase).toBe('error'); +}); + +test('a retry that fails identically still outlives a completing check', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const pending: Array<(version: string) => void> = []; + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + pending.push((version) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson(version)); + }); + }); + + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const autoUpdater = new FakeAutoUpdater(); + autoUpdater.setFeedURL = () => { + throw new Error('Could not set the update feed.'); + }; + const updater = createUpdater({ + arch: 'arm64', + autoUpdater, + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + await updater.applyUpdate(); + expect(updater.getStatus().phase).toBe('error'); + + const check = updater.checkForUpdates({ force: true }); + await waitFor(() => pending.length === 1); + await updater.applyUpdate(); + pending[0]('1.9.3'); + await check; + + expect(updater.getStatus().phase).toBe('error'); + expect(updater.getStatus().message).toContain('Could not set the update feed.'); +}); + +test('a dismissal made after a forced check was queued survives it', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const pending: Array<(version: string) => void> = []; + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + pending.push((version) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson(version)); + }); + }); + + await writeState(directory.path, { + lastCheckedAt: '2026-01-01T00:00:00.000Z', + latestVersion: '1.9.3', + }); + + const updater = createUpdater({ + arch: 'arm64', + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const scheduled = updater.checkForUpdates(); + await waitFor(() => pending.length === 1); + const forced = updater.checkForUpdates({ force: true }); + updater.dismissUpdate(); + + pending[0]('1.9.3'); + await scheduled; + await waitFor(() => pending.length === 2); + pending[1]('1.9.3'); + + expect(await forced).toEqual({ currentVersion: '1.9.2', phase: 'idle', strategy: 'squirrel' }); + + const persisted = JSON.parse( + await readFile(join(directory.path, 'update-state.json'), 'utf8'), + ) as { dismissedVersion?: string }; + expect(persisted.dismissedVersion).toBe('1.9.3'); +}); + +test('an apply failure after a check was queued survives its completion', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const pending: Array<(version: string) => void> = []; + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + pending.push((version) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson(version)); + }); + }); + + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const autoUpdater = new FakeAutoUpdater(); + const updater = createUpdater({ + arch: 'arm64', + autoUpdater, + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const forced = updater.checkForUpdates({ force: true }); + await waitFor(() => pending.length === 1); + const scheduled = updater.checkForUpdates(); + + await updater.applyUpdate(); + autoUpdater.emit('error', new Error('Squirrel download failed')); + + pending[0]('1.9.3'); + await forced; + await scheduled; + + expect(updater.getStatus().phase).toBe('error'); + expect(updater.getStatus().message).toContain('Squirrel download failed'); +}); + +test('checks leave a handed-off installer alone until relaunch', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await using downloads = await createTemporaryDirectory('codiff-downloads-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + let requests = 0; + const { disposable: _server, origin } = await startReleaseServer((request, response) => { + if (request.url === '/asset.deb') { + response.end('deb-bytes'); + return; + } + + requests++; + response.setHeader('content-type', 'application/json'); + response.end( + releaseJson('1.9.3', [ + { + browser_download_url: `${origin}/asset.deb`, + digest: sha256('deb-bytes'), + name: 'codiff_1.9.3_amd64.deb', + }, + ]), + ); + }); + + const updater = createUpdater({ + arch: 'x64', + configDir: directory.path, + currentVersion: '1.9.2', + downloadDirectory: downloads.path, + isPackaged: true, + linuxFlavor: 'deb', + openPath: async () => '', + platform: 'linux', + releaseUrl: `${origin}/`, + strategy: 'download', + }); + + await updater.applyUpdate(); + expect(updater.getStatus().phase).toBe('installerReady'); + const requestsAfterApply = requests; + + expect((await updater.checkForUpdates()).phase).toBe('installerReady'); + expect((await updater.checkForUpdates({ force: true })).phase).toBe('installerReady'); + expect(requests).toBe(requestsAfterApply); +}); + +test('a throttled check does not erase an apply failure', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const autoUpdater = new FakeAutoUpdater(); + const updater = createUpdater({ + arch: 'arm64', + autoUpdater, + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + strategy: 'squirrel', + }); + + await updater.applyUpdate(); + autoUpdater.emit('error', new Error('Squirrel download failed')); + expect(updater.getStatus().phase).toBe('error'); + + const status = await updater.checkForUpdates(); + + expect(status.phase).toBe('error'); + expect(status.message).toContain('Squirrel download failed'); +}); + +test('applyLatest force-checks and applies in one step', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson('1.9.3')); + }); + + const autoUpdater = new FakeAutoUpdater(); + const updater = createUpdater({ + arch: 'arm64', + autoUpdater, + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const status = await updater.applyLatest(); + + expect(status).toEqual({ + currentVersion: '1.9.2', + phase: 'updating', + strategy: 'squirrel', + version: '1.9.3', + }); + expect(autoUpdater.feedURL).toEqual({ + url: 'https://update.electronjs.org/nkzw-tech/codiff/darwin-arm64/1.9.2', + }); + expect(autoUpdater.checkForUpdatesCalls).toBe(1); +}); + +test('applyLatest stays idle when already up to date', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson('1.9.2')); + }); + + const autoUpdater = new FakeAutoUpdater(); + const updater = createUpdater({ + arch: 'arm64', + autoUpdater, + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + expect(await updater.applyLatest()).toEqual({ + currentVersion: '1.9.2', + phase: 'idle', + strategy: 'squirrel', + }); + expect(autoUpdater.feedURL).toBeNull(); + expect(autoUpdater.checkForUpdatesCalls).toBe(0); +}); + +test('applyLatest surfaces a failed check as an error status', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + response.statusCode = 500; + response.end('nope'); + }); + + const autoUpdater = new FakeAutoUpdater(); + const updater = createUpdater({ + arch: 'arm64', + autoUpdater, + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + log: () => {}, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const status = await updater.applyLatest(); + + expect(status.phase).toBe('error'); + expect(status.message).toBeTruthy(); + expect(updater.getStatus().phase).toBe('error'); + expect(autoUpdater.checkForUpdatesCalls).toBe(0); +}); + +test('a failed applyLatest check does not cancel an apply started meanwhile', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const pending: Array<() => void> = []; + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + pending.push(() => { + response.statusCode = 500; + response.end('nope'); + }); + }); + + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const autoUpdater = new FakeAutoUpdater(); + const updater = createUpdater({ + arch: 'arm64', + autoUpdater, + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + log: () => {}, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const latest = updater.applyLatest(); + await waitFor(() => pending.length === 1); + await updater.applyUpdate(); + pending[0](); + await latest; + + expect(updater.getStatus().phase).toBe('updating'); + + autoUpdater.emit('update-downloaded'); + expect(autoUpdater.quitAndInstallCalls).toBe(1); +}); + +test('an older failed applyLatest defers to a newer successful one', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const pending: Array<(respond: { status?: number; version?: string }) => void> = []; + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + pending.push(({ status: statusCode, version }) => { + if (statusCode) { + response.statusCode = statusCode; + response.end('nope'); + return; + } + response.setHeader('content-type', 'application/json'); + response.end(releaseJson(version ?? '0.0.1')); + }); + }); + + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const autoUpdater = new FakeAutoUpdater(); + const updater = createUpdater({ + arch: 'arm64', + autoUpdater, + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + log: () => {}, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const first = updater.applyLatest(); + await waitFor(() => pending.length === 1); + const second = updater.applyLatest(); + + pending[0]({ status: 500 }); + await first; + await waitFor(() => pending.length === 2); + pending[1]({ version: '1.9.4' }); + const status = await second; + + expect(status).toEqual({ + currentVersion: '1.9.2', + phase: 'updating', + strategy: 'squirrel', + version: '1.9.4', + }); + expect(updater.getStatus().phase).toBe('updating'); + expect(autoUpdater.checkForUpdatesCalls).toBe(1); +}); + +test('a newer applyLatest owns the apply over an older successful one', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + const pending: Array<(version: string) => void> = []; + const { disposable: _server, origin } = await startReleaseServer((_request, response) => { + pending.push((version) => { + response.setHeader('content-type', 'application/json'); + response.end(releaseJson(version)); + }); + }); + + await writeState(directory.path, { + lastCheckedAt: recentCheck(), + latestVersion: '1.9.3', + }); + + const autoUpdater = new FakeAutoUpdater(); + const updater = createUpdater({ + arch: 'arm64', + autoUpdater, + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + releaseUrl: `${origin}/`, + strategy: 'squirrel', + }); + + const first = updater.applyLatest(); + await waitFor(() => pending.length === 1); + const second = updater.applyLatest(); + + pending[0]('1.9.3'); + await first; + await waitFor(() => pending.length === 2); + pending[1]('1.9.4'); + const status = await second; + + expect(status).toEqual({ + currentVersion: '1.9.2', + phase: 'updating', + strategy: 'squirrel', + version: '1.9.4', + }); + expect(updater.getStatus()).toEqual({ + currentVersion: '1.9.2', + phase: 'updating', + strategy: 'squirrel', + version: '1.9.4', + }); + expect(autoUpdater.checkForUpdatesCalls).toBe(1); +}); + +test('applyUpdate is a no-op unless an update is available', async () => { + await using directory = await createTemporaryDirectory('codiff-updater-'); + + const autoUpdater = new FakeAutoUpdater(); + const updater = createUpdater({ + arch: 'arm64', + autoUpdater, + configDir: directory.path, + currentVersion: '1.9.2', + isPackaged: true, + platform: 'darwin', + strategy: 'squirrel', + }); + + expect((await updater.applyUpdate()).phase).toBe('idle'); + expect(autoUpdater.checkForUpdatesCalls).toBe(0); + expect(autoUpdater.feedURL).toBeNull(); +}); diff --git a/electron/config.cjs b/electron/config.cjs index 0807fb41..68bdd554 100644 --- a/electron/config.cjs +++ b/electron/config.cjs @@ -233,6 +233,10 @@ const mergeConfig = (raw) => { }, settings: { agentBackend: normalizeAgentBackend(rawSettings.agentBackend), + checkForUpdates: + typeof rawSettings.checkForUpdates === 'boolean' + ? rawSettings.checkForUpdates + : defaults.settings.checkForUpdates, claudeModel: typeof rawSettings.claudeModel === 'string' ? rawSettings.claudeModel diff --git a/electron/main.cjs b/electron/main.cjs index 277fce7e..f05a06a7 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -5,6 +5,7 @@ const { basename, dirname, join, relative, resolve } = require('node:path'); const { pathToFileURL } = require('node:url'); const { app, + autoUpdater, BrowserWindow, clipboard, dialog, @@ -56,6 +57,8 @@ const { writeConfig, } = require('./config.cjs'); const { readReviewAssistantReply } = require('./review-assist.cjs'); +const { releasePageUrl } = require('./update-check.cjs'); +const { createUpdater, resolveUpdateStrategy } = require('./updater.cjs'); const { parseReviewUrl, resolveReviewUrl } = require('./review-source.cjs'); const { getPlanWindowTitle, @@ -580,6 +583,12 @@ const buildApplicationMenu = () => label: 'Codiff', submenu: [ { role: 'about' }, + { + click: () => { + void checkForUpdatesFromMenu(); + }, + label: 'Check for Updates…', + }, { type: 'separator' }, { click: (_menuItem, browserWindow) => @@ -1192,6 +1201,79 @@ const focusOrCreateWindow = ( return createWindow(repositoryPath, launchOptions, identity); }; +const INITIAL_UPDATE_CHECK_DELAY_MS = 10 * 1000; +const UPDATE_CHECK_TIMER_INTERVAL_MS = 4 * 60 * 60 * 1000; + +/** @type {ReturnType | null} */ +let updater = null; + +/** @param {import('../core/types.ts').CodiffUpdateStatus} status */ +const sendUpdateStatusChanged = (status) => { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed() && !window.webContents.isDestroyed()) { + window.webContents.send('codiff:updateStatusChanged', status); + } + } +}; + +const hasSquirrelUpdateExe = () => + process.platform === 'win32' && existsSync(join(dirname(process.execPath), '..', 'Update.exe')); + +const detectLinuxPackageFlavor = () => + existsSync('/etc/debian_version') + ? /** @type {const} */ ('deb') + : existsSync('/etc/redhat-release') + ? /** @type {const} */ ('rpm') + : null; + +const initUpdater = () => { + updater = createUpdater({ + arch: process.arch, + autoUpdater, + currentVersion: app.getVersion(), + downloadDirectory: app.getPath('downloads'), + isPackaged: app.isPackaged, + linuxFlavor: process.platform === 'linux' ? detectLinuxPackageFlavor() : null, + onStatusChange: sendUpdateStatusChanged, + openExternal: (url) => shell.openExternal(url), + openPath: (path) => shell.openPath(path), + platform: process.platform, + strategy: resolveUpdateStrategy({ + hasSquirrelUpdateExe: hasSquirrelUpdateExe(), + platform: process.platform, + }), + }); +}; + +const runScheduledUpdateCheck = () => { + if (updater && config.settings.checkForUpdates) { + void updater.checkForUpdates().catch(() => {}); + } +}; + +const checkForUpdatesFromMenu = async () => { + if (!updater) { + return; + } + + try { + const status = await updater.checkForUpdates({ force: true }); + if (status.phase === 'idle') { + void dialog.showMessageBox({ + message: `Codiff ${app.getVersion()} is up to date.`, + type: 'info', + }); + } + } catch (error) { + void dialog.showMessageBox({ + message: `Checking for updates failed: ${ + error instanceof Error ? error.message : String(error) + }`, + type: 'error', + }); + } +}; + const lock = !squirrelStartup && app.requestSingleInstanceLock({ @@ -1215,6 +1297,9 @@ if (squirrelStartup || !lock) { getInitialRepositoryPath(launchPath, launchOptions, config.settings.lastRepositoryPath), launchOptions, ); + if (launchOptions.applyUpdate) { + void updater?.applyLatest().catch(() => {}); + } }); app.on('ready', () => { @@ -1243,11 +1328,18 @@ if (squirrelStartup || !lock) { } }); + initUpdater(); + setTimeout(runScheduledUpdateCheck, INITIAL_UPDATE_CHECK_DELAY_MS); + setInterval(runScheduledUpdateCheck, UPDATE_CHECK_TIMER_INTERVAL_MS); + const launchOptions = getLaunchOptions(); focusOrCreateWindow( getInitialRepositoryPath(getLaunchPath(), launchOptions, config.settings.lastRepositoryPath), launchOptions, ); + if (launchOptions.applyUpdate) { + void updater?.applyLatest().catch(() => {}); + } watchConfig((nextConfig) => { config = { @@ -1314,6 +1406,25 @@ if (squirrelStartup || !lock) { }); } +ipcMain.handle('codiff:getUpdateStatus', () => + updater ? updater.getStatus() : { currentVersion: app.getVersion(), phase: 'idle' }, +); + +ipcMain.handle('codiff:applyUpdate', () => + updater ? updater.applyUpdate() : { currentVersion: app.getVersion(), phase: 'idle' }, +); + +ipcMain.handle('codiff:dismissUpdate', () => + updater ? updater.dismissUpdate() : { currentVersion: app.getVersion(), phase: 'idle' }, +); + +ipcMain.handle('codiff:openReleasePage', () => { + const version = updater?.getStatus().version; + return shell.openExternal( + version ? releasePageUrl(version) : 'https://github.com/nkzw-tech/codiff/releases', + ); +}); + ipcMain.handle('codiff:getRepositoryState', async (event, source) => { const repositoryPath = windowRepositories.get(event.sender.id) || getLaunchPath(); const launchOptions = windowLaunchOptions.get(event.sender.id); diff --git a/electron/main/command-line.cjs b/electron/main/command-line.cjs index 7a8760a3..b2406b17 100644 --- a/electron/main/command-line.cjs +++ b/electron/main/command-line.cjs @@ -111,6 +111,9 @@ const parseCommandLineArguments = (commandLine = process.argv) => { allowPositionals: true, args, options: { + 'apply-update': { + type: 'boolean', + }, commit: { type: 'string', }, @@ -313,6 +316,7 @@ const parseCommandLineArguments = (commandLine = process.argv) => { ); return { launchOptions: { + ...(values['apply-update'] === true ? { applyUpdate: true } : {}), ...(agentBackend ? { agentBackend } : {}), ...(claudeSessionId ? { claudeSessionId } : {}), ...(codexSessionId ? { codexSessionId } : {}), diff --git a/electron/preload.cjs b/electron/preload.cjs index e4288cf4..ccb7900b 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -14,7 +14,9 @@ if (document.documentElement) { /** @type {Window['codiff']} */ const codiff = { + applyUpdate: () => ipcRenderer.invoke('codiff:applyUpdate'), askReviewAssistant: (request) => ipcRenderer.invoke('codiff:askReviewAssistant', request), + dismissUpdate: () => ipcRenderer.invoke('codiff:dismissUpdate'), createWalkthroughCommit: (request) => ipcRenderer.invoke('codiff:createWalkthroughCommit', request), completePlan: (review, status) => ipcRenderer.invoke('codiff:completePlan', review, status), @@ -36,6 +38,7 @@ const codiff = { ipcRenderer.invoke('codiff:getRepositoryHistory', limit, source), getRepositoryState: (source) => ipcRenderer.invoke('codiff:getRepositoryState', source), getTerminalHelperStatus: () => ipcRenderer.invoke('codiff:getTerminalHelperStatus'), + getUpdateStatus: () => ipcRenderer.invoke('codiff:getUpdateStatus'), getNarrativeWalkthrough: (source, options) => ipcRenderer.invoke('codiff:getNarrativeWalkthrough', source, options), installAgentSkill: () => ipcRenderer.invoke('codiff:installAgentSkill'), @@ -124,7 +127,14 @@ const codiff = { ipcRenderer.on('codiff:refreshRequest', listener); return () => ipcRenderer.removeListener('codiff:refreshRequest', listener); }, + onUpdateStatusChanged: (callback) => { + /** @param {Electron.IpcRendererEvent} _event @param {import('../core/types.ts').CodiffUpdateStatus} status */ + const listener = (_event, status) => callback(status); + ipcRenderer.on('codiff:updateStatusChanged', listener); + return () => ipcRenderer.removeListener('codiff:updateStatusChanged', listener); + }, openConfigFile: () => ipcRenderer.invoke('codiff:openConfigFile'), + openReleasePage: () => ipcRenderer.invoke('codiff:openReleasePage'), openFile: (path) => ipcRenderer.invoke('codiff:openFile', path), openRepositoryFolder: () => ipcRenderer.invoke('codiff:openRepositoryFolder'), resolvePullRequestUrl: (value) => ipcRenderer.invoke('codiff:resolvePullRequestUrl', value), diff --git a/electron/update-check.cjs b/electron/update-check.cjs new file mode 100644 index 00000000..dd5d9a2f --- /dev/null +++ b/electron/update-check.cjs @@ -0,0 +1,222 @@ +// @ts-check + +const { existsSync, mkdirSync, readFileSync, writeFileSync } = require('node:fs'); +const { homedir } = require('node:os'); +const { join } = require('node:path'); + +const LATEST_RELEASE_URL = 'https://api.github.com/repos/nkzw-tech/codiff/releases/latest'; +const UPDATE_CHECK_INTERVAL_MS = 20 * 60 * 60 * 1000; +const USER_AGENT = 'codiff-update-check'; + +const getDefaultConfigDir = () => join(homedir(), '.codiff'); + +/** + * @typedef {{ + * lastCheckedAt: string; + * latestVersion: string; + * dismissedVersion?: string; + * }} UpdateState + * @typedef {{ digest?: string; name: string; url: string }} ReleaseAsset + */ + +/** + * @param {string} version + * @returns {[number, number, number] | null} + */ +const parseVersion = (version) => { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; +}; + +/** + * @param {string} latest + * @param {string} current + * @returns {boolean} + */ +const isNewerVersion = (latest, current) => { + const latestParts = parseVersion(latest); + const currentParts = parseVersion(current); + + if (!latestParts || !currentParts) { + return false; + } + + for (let index = 0; index < 3; index++) { + if (latestParts[index] !== currentParts[index]) { + return latestParts[index] > currentParts[index]; + } + } + + return false; +}; + +/** + * @param {string} tag + * @returns {string | null} + */ +const extractVersionFromTag = (tag) => { + const version = tag.startsWith('v') ? tag.slice(1) : tag; + return parseVersion(version) ? version : null; +}; + +/** + * @param {unknown} raw + * @returns {UpdateState | null} + */ +const parseUpdateState = (raw) => { + if (typeof raw !== 'object' || raw === null) { + return null; + } + + const { dismissedVersion, lastCheckedAt, latestVersion } = + /** @type {Record} */ (raw); + + if ( + typeof lastCheckedAt !== 'string' || + Number.isNaN(Date.parse(lastCheckedAt)) || + typeof latestVersion !== 'string' + ) { + return null; + } + + return { + lastCheckedAt, + latestVersion, + ...(typeof dismissedVersion === 'string' ? { dismissedVersion } : {}), + }; +}; + +/** + * @param {string} [configDir] + * @returns {UpdateState | null} + */ +const readUpdateState = (configDir) => { + const filePath = join(configDir ?? getDefaultConfigDir(), 'update-state.json'); + + if (!existsSync(filePath)) { + return null; + } + + try { + return parseUpdateState(JSON.parse(readFileSync(filePath, 'utf8'))); + } catch { + return null; + } +}; + +/** + * @param {UpdateState} state + * @param {string} [configDir] + */ +const writeUpdateState = (state, configDir) => { + const dir = configDir ?? getDefaultConfigDir(); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeFileSync(join(dir, 'update-state.json'), JSON.stringify(state, null, 2) + '\n'); +}; + +/** + * @param {UpdateState | null} state + * @param {number} now + * @returns {boolean} + */ +const shouldCheckForUpdates = (state, now) => { + if (!state) { + return true; + } + + const lastCheckedAt = Date.parse(state.lastCheckedAt); + return Number.isNaN(lastCheckedAt) || now - lastCheckedAt >= UPDATE_CHECK_INTERVAL_MS; +}; + +/** + * @param {UpdateState | null} state + * @param {string} currentVersion + * @returns {{ version: string } | null} + */ +const getAvailableUpdate = (state, currentVersion) => { + if ( + !state || + state.latestVersion === state.dismissedVersion || + !isNewerVersion(state.latestVersion, currentVersion) + ) { + return null; + } + + return { version: state.latestVersion }; +}; + +/** + * @param {string} [url] + * @returns {Promise<{ assets: Array; version: string }>} + */ +const fetchLatestRelease = async (url) => { + const response = await fetch(url ?? LATEST_RELEASE_URL, { + headers: { + accept: 'application/vnd.github+json', + 'user-agent': USER_AGENT, + }, + }); + + if (!response.ok) { + throw new Error(`Update check failed with status ${response.status}.`); + } + + const release = /** @type {Record} */ (await response.json()); + const version = + typeof release.tag_name === 'string' ? extractVersionFromTag(release.tag_name) : null; + + if (!version) { + throw new Error(`Update check found no release version in tag ${String(release.tag_name)}.`); + } + + const assets = Array.isArray(release.assets) + ? release.assets.flatMap((asset) => + typeof asset === 'object' && + asset !== null && + typeof asset.name === 'string' && + typeof asset.browser_download_url === 'string' + ? [ + { + ...(typeof asset.digest === 'string' ? { digest: asset.digest } : {}), + name: asset.name, + url: asset.browser_download_url, + }, + ] + : [], + ) + : []; + + return { assets, version }; +}; + +/** + * @param {string} platform + * @param {string} arch + * @param {string} version + * @returns {string} + */ +const updateFeedUrl = (platform, arch, version) => + `https://update.electronjs.org/nkzw-tech/codiff/${platform}-${arch}/${version}`; + +/** + * @param {string} version + * @returns {string} + */ +const releasePageUrl = (version) => `https://github.com/nkzw-tech/codiff/releases/tag/v${version}`; + +module.exports = { + extractVersionFromTag, + fetchLatestRelease, + getAvailableUpdate, + isNewerVersion, + LATEST_RELEASE_URL, + readUpdateState, + releasePageUrl, + shouldCheckForUpdates, + UPDATE_CHECK_INTERVAL_MS, + updateFeedUrl, + writeUpdateState, +}; diff --git a/electron/updater.cjs b/electron/updater.cjs new file mode 100644 index 00000000..161f09e4 --- /dev/null +++ b/electron/updater.cjs @@ -0,0 +1,504 @@ +// @ts-check + +const { createHash } = require('node:crypto'); +const { createWriteStream } = require('node:fs'); +const { rm } = require('node:fs/promises'); +const { join } = require('node:path'); +const { Readable, Transform } = require('node:stream'); +const { pipeline } = require('node:stream/promises'); +const { + fetchLatestRelease, + getAvailableUpdate, + readUpdateState, + releasePageUrl, + shouldCheckForUpdates, + updateFeedUrl, + writeUpdateState, +} = require('./update-check.cjs'); + +/** + * @typedef {import('./update-check.cjs').UpdateState} UpdateState + * @typedef {{ name: string; url: string }} ReleaseAsset + * @typedef {'available' | 'error' | 'idle' | 'installerReady' | 'updating'} UpdatePhase + * @typedef {'download' | 'manual' | 'squirrel'} UpdateStrategy + * @typedef {{ + * currentVersion: string; + * message?: string; + * phase: UpdatePhase; + * strategy?: UpdateStrategy; + * version?: string; + * }} UpdateStatus + * @typedef {{ + * checkForUpdates: () => void; + * on: (event: string, listener: (...args: Array) => void) => unknown; + * quitAndInstall: () => void; + * setFeedURL: (options: { url: string }) => void; + * }} SquirrelAutoUpdater + */ + +/** + * @param {{ hasSquirrelUpdateExe: boolean; platform: string }} options + * @returns {UpdateStrategy} + */ +const resolveUpdateStrategy = ({ hasSquirrelUpdateExe, platform }) => + platform === 'darwin' || (platform === 'win32' && hasSquirrelUpdateExe) + ? 'squirrel' + : platform === 'win32' + ? // Windows releases ship only a ZIP; downloading it cannot replace the + // installed app, so the update hands off to the release page instead. + 'manual' + : 'download'; + +// Linux packages spell architectures differently per format: Debian uses +// amd64/arm64 while RPM uses x86_64/aarch64. Node's process.arch is the key. +const LINUX_ARCH_ALIASES = /** @type {Record>} */ ({ + arm64: ['arm64', 'aarch64'], + x64: ['x64', 'amd64', 'x86_64'], +}); + +/** + * @param {ReadonlyArray} assets + * @param {{ arch: string; linuxFlavor?: 'deb' | 'rpm' | null; platform: string }} options + * @returns {ReleaseAsset | null} + */ +const pickReleaseAsset = (assets, { arch, linuxFlavor, platform }) => { + /** @param {(asset: ReleaseAsset) => boolean} predicate */ + const find = (predicate) => assets.find(predicate) ?? null; + + if (platform === 'darwin') { + return find(({ name }) => name.includes(`darwin-${arch}`) && name.endsWith('.zip')); + } + + if (platform === 'win32') { + return find( + ({ name }) => name.includes('win32') && name.includes(arch) && name.endsWith('.zip'), + ); + } + + if (platform === 'linux' && linuxFlavor) { + const aliases = LINUX_ARCH_ALIASES[arch] ?? [arch]; + const otherAliases = Object.values(LINUX_ARCH_ALIASES) + .flat() + .filter((alias) => !aliases.includes(alias)); + return ( + find( + ({ name }) => + name.endsWith(`.${linuxFlavor}`) && aliases.some((alias) => name.includes(alias)), + ) ?? + // An installer with no architecture marker predates multi-architecture + // releases; one marked for another architecture would not run here. + find( + ({ name }) => + name.endsWith(`.${linuxFlavor}`) && !otherAliases.some((alias) => name.includes(alias)), + ) + ); + } + + return null; +}; + +/** + * @param {{ + * arch: string; + * autoUpdater?: SquirrelAutoUpdater; + * configDir?: string; + * currentVersion: string; + * downloadDirectory?: string; + * isPackaged: boolean; + * linuxFlavor?: 'deb' | 'rpm' | null; + * log?: (message: string) => void; + * onStatusChange?: (status: UpdateStatus) => void; + * openExternal?: (url: string) => Promise; + * openPath?: (path: string) => Promise; + * platform: string; + * releaseUrl?: string; + * strategy: UpdateStrategy; + * }} options + */ +const createUpdater = ({ + arch, + autoUpdater, + configDir, + currentVersion, + downloadDirectory, + isPackaged, + linuxFlavor, + log, + onStatusChange, + openExternal, + openPath, + platform, + releaseUrl, + strategy, +}) => { + const logError = log ?? ((/** @type {string} */ message) => console.error(message)); + + /** @returns {UpdateStatus} */ + const statusFromState = () => { + const update = getAvailableUpdate(readUpdateState(configDir), currentVersion); + return update + ? { currentVersion, phase: 'available', strategy, version: update.version } + : { currentVersion, phase: 'idle', strategy }; + }; + + /** @type {UpdateStatus} */ + let status = statusFromState(); + + // Counts user and auto-updater actions (apply attempts, their outcomes, + // dismissals). Checks snapshot it when they are requested and refuse to + // overwrite the status once it moved: the action happened after the caller + // asked for the check, so it is newer information. Check-driven transitions + // deliberately do not count; a queued check may build on its predecessor. + let actionGeneration = 0; + + /** @param {UpdateStatus} next */ + const setStatus = (next) => { + if ( + next.phase === status.phase && + next.version === status.version && + next.message === status.message + ) { + return status; + } + + status = { ...next, strategy }; + onStatusChange?.({ ...status }); + return status; + }; + + /** + * @param {string} message + * @param {string} [version] + */ + const setError = (message, version) => + setStatus({ + currentVersion, + message, + phase: 'error', + ...(version ? { version } : {}), + }); + + if (autoUpdater) { + autoUpdater.on('update-downloaded', () => { + if (status.phase === 'updating') { + autoUpdater.quitAndInstall(); + } + }); + autoUpdater.on('update-not-available', () => { + if (status.phase === 'updating') { + actionGeneration++; + setError('The update is not available for download yet. Try again later.', status.version); + } + }); + autoUpdater.on('error', (error) => { + if (status.phase === 'updating') { + actionGeneration++; + setError(error instanceof Error ? error.message : String(error), status.version); + } + }); + } + + /** + * @param {boolean} force + * @param {number} generationAtStart + * @param {string | undefined} dismissedBefore + */ + const performCheck = async (force, generationAtStart, dismissedBefore) => { + const state = readUpdateState(configDir); + if (!force && !shouldCheckForUpdates(state, Date.now())) { + // A throttled check brings no new information; never move the status. + return { ...status }; + } + + try { + const release = await fetchLatestRelease(releaseUrl); + + // Re-read after the network round trip: a dismissal may have been + // persisted while the request was in flight and must survive. A forced + // check is explicit user intent to see updates again, so it drops the + // dismissal it started with, but never one made after it started. + const dismissedNow = readUpdateState(configDir)?.dismissedVersion; + const dismissedVersion = force && dismissedNow === dismissedBefore ? undefined : dismissedNow; + writeUpdateState( + { + lastCheckedAt: new Date().toISOString(), + latestVersion: release.version, + ...(dismissedVersion ? { dismissedVersion } : {}), + }, + configDir, + ); + } catch (error) { + logError(`Update check failed: ${error instanceof Error ? error.message : String(error)}`); + if (force) { + throw error; + } + } + + // An action happened after this check was requested (an apply started, + // failed, or handed off; or the user dismissed). That is newer + // information than this check; do not overwrite it. + if (actionGeneration !== generationAtStart) { + return { ...status }; + } + + return { ...setStatus(statusFromState()) }; + }; + + // Checks are serialized: only one release request is ever in flight, and a + // queued check runs against the state its predecessor persisted (usually + // resolving from the fresh cache without another request). This makes + // out-of-order completions impossible and keeps every failure owned by the + // caller that triggered it. + /** @type {Promise} */ + let pendingCheck = Promise.resolve(); + + const checkForUpdates = ({ force = false } = {}) => { + // installerReady is terminal until relaunch: the user already has the new + // installer open, so checking again can only produce confusing states. + if (!isPackaged || status.phase === 'updating' || status.phase === 'installerReady') { + return Promise.resolve({ ...status }); + } + + // Snapshot the caller's context now, not when the queued check finally + // runs: a dismissal or apply transition made while this check waits in + // the queue happened after the caller acted and must win over it. + const generationAtStart = actionGeneration; + const dismissedBefore = readUpdateState(configDir)?.dismissedVersion; + + const current = pendingCheck.then(() => + performCheck(force, generationAtStart, dismissedBefore), + ); + pendingCheck = current.then( + () => undefined, + () => undefined, + ); + return current; + }; + + const applySquirrelUpdate = () => { + if (!autoUpdater) { + return setError('The updater is unavailable in this build.', status.version); + } + + const version = status.version; + // Enter the updating phase before setFeedURL so even a retry that fails + // with an identical error produces status transitions; otherwise a check + // completing mid-retry could overwrite the fresh failure. + const next = setStatus({ currentVersion, phase: 'updating', version }); + try { + autoUpdater.setFeedURL({ url: updateFeedUrl(platform, arch, currentVersion) }); + } catch (error) { + return setError(error instanceof Error ? error.message : String(error), version); + } + + autoUpdater.checkForUpdates(); + return next; + }; + + const applyDownloadUpdate = async () => { + const version = status.version; + // Enter the updating phase before any await so a concurrent applyUpdate + // call sees it and becomes a no-op instead of downloading twice. + setStatus({ currentVersion, phase: 'updating', version }); + + try { + const release = await fetchLatestRelease(releaseUrl); + const asset = pickReleaseAsset(release.assets, { arch, linuxFlavor, platform }); + + if (!asset || !downloadDirectory || !openPath) { + return setError('No download is available for this platform.', version); + } + + // Installers are only opened after their bytes match the checksum the + // release published; a download nobody can verify is not an update. + const expectedDigest = asset.digest?.startsWith('sha256:') + ? asset.digest.slice('sha256:'.length) + : null; + if (!expectedDigest) { + throw new Error('The release does not publish a SHA-256 checksum for this download.'); + } + + const response = await fetch(asset.url); + if (!response.ok || !response.body) { + throw new Error(`Downloading the update failed with status ${response.status}.`); + } + + const path = join(downloadDirectory, asset.name); + const hash = createHash('sha256'); + try { + await pipeline( + Readable.fromWeb(/** @type {import('node:stream/web').ReadableStream} */ (response.body)), + new Transform({ + transform(chunk, _encoding, callback) { + hash.update(chunk); + callback(null, chunk); + }, + }), + createWriteStream(path), + ); + + if (hash.digest('hex') !== expectedDigest.toLowerCase()) { + throw new Error('The downloaded update failed its integrity check. Try again later.'); + } + } catch (error) { + await rm(path, { force: true }); + throw error; + } + + const openError = await openPath(path); + if (openError) { + throw new Error(openError); + } + + return setStatus({ currentVersion, phase: 'installerReady', version: release.version }); + } catch (error) { + return setError(error instanceof Error ? error.message : String(error), version); + } + }; + + // The manual strategy never leaves the available phase, so the phase guard + // in applyUpdate cannot serialize it. The in-flight hand-off is tracked by + // its URL: a repeat click for the same page shares the pending outcome + // (one browser tab, one result), while a request for a different page, such + // as applyLatest discovering a newer version, supersedes it. + /** @type {{ generation: number; promise: Promise; url: string } | null} */ + let manualOpen = null; + + const manualUpdateUrl = () => + status.version + ? releasePageUrl(status.version) + : 'https://github.com/nkzw-tech/codiff/releases'; + + const applyManualUpdate = async () => { + const version = status.version; + const url = manualUpdateUrl(); + if (!openExternal) { + return setError('The updater is unavailable in this build.', version); + } + + const superseded = manualOpen; + // Captured before waiting on the older hand-off: an action that lands + // during that wait (a dismissal, another apply) owns the status, and + // opening the page for this stale request would resurrect it. + const generationAtStart = actionGeneration; + const attempt = (async () => { + if (superseded) { + // Let the older hand-off settle first; its completion yields to this + // newer action through the generation check below. + await superseded.promise; + if (actionGeneration !== generationAtStart) { + return { ...status }; + } + } + + try { + await openExternal(url); + // Nothing was installed; the update stays available until the user + // replaces the app themselves. Recomputing from state also clears a + // previous open failure once a retry reaches the release page. An + // action taken while the browser was opening is newer information + // and owns the status instead. + return actionGeneration === generationAtStart + ? setStatus(statusFromState()) + : { ...status }; + } catch (error) { + return actionGeneration === generationAtStart + ? setError(error instanceof Error ? error.message : String(error), version) + : { ...status }; + } + })(); + + manualOpen = { generation: generationAtStart, promise: attempt, url }; + try { + return await attempt; + } finally { + if (manualOpen?.promise === attempt) { + manualOpen = null; + } + } + }; + + const applyUpdate = async () => { + if (status.phase !== 'available' && status.phase !== 'error') { + return { ...status }; + } + + // A repeat click while the same hand-off is still opening is not a new + // action; share the pending outcome instead of opening a second tab. The + // generation must still match: an action since the hand-off was queued + // (a dismissal, then a fresh request) means it will yield without ever + // opening, so a new request must queue its own hand-off instead. + if ( + strategy === 'manual' && + manualOpen && + manualOpen.url === manualUpdateUrl() && + manualOpen.generation === actionGeneration + ) { + return { ...(await manualOpen.promise) }; + } + + actionGeneration++; + return { + ...(strategy === 'squirrel' + ? applySquirrelUpdate() + : strategy === 'manual' + ? await applyManualUpdate() + : await applyDownloadUpdate()), + }; + }; + + let applyLatestSequence = 0; + + const applyLatest = async () => { + const applyLatestId = ++applyLatestSequence; + const generationAtStart = actionGeneration; + let checked; + try { + checked = await checkForUpdates({ force: true }); + } catch (error) { + // An apply or dismissal that started while the check was pending, or a + // newer applyLatest request still in flight, is newer information than + // this failure; leave the status to it. + if (applyLatestId !== applyLatestSequence || actionGeneration !== generationAtStart) { + return { ...status }; + } + + // The caller asked for an update, so a failed check is an update + // failure; surface it in the banner instead of rejecting into a void. + actionGeneration++; + return { ...setError(error instanceof Error ? error.message : String(error)) }; + } + + // A newer applyLatest request is pending; let it own the apply so the + // freshest release wins. + if (applyLatestId !== applyLatestSequence) { + return { ...status }; + } + + return checked.phase === 'available' ? applyUpdate() : checked; + }; + + const dismissUpdate = () => { + actionGeneration++; + const state = readUpdateState(configDir); + if (state) { + writeUpdateState({ ...state, dismissedVersion: state.latestVersion }, configDir); + } + + return { ...setStatus(statusFromState()) }; + }; + + return { + applyLatest, + applyUpdate, + checkForUpdates, + dismissUpdate, + getStatus: () => ({ ...status }), + }; +}; + +module.exports = { + createUpdater, + pickReleaseAsset, + resolveUpdateStrategy, +};