From 8d505bd7f44436d2f33b30298db09054648f760b Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 01:20:56 +0200 Subject: [PATCH 01/89] test(update): add coverage for update-check version, state and release fetching --- electron/__tests__/update-check.test.ts | 259 ++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 electron/__tests__/update-check.test.ts diff --git a/electron/__tests__/update-check.test.ts b/electron/__tests__/update-check.test.ts new file mode 100644 index 0000000..179eac6 --- /dev/null +++ b/electron/__tests__/update-check.test.ts @@ -0,0 +1,259 @@ +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', + 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([ + { + 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([]); +}); From 9d25b72ad17c3fc302b6621abe03347c68a4ccea Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 01:21:48 +0200 Subject: [PATCH 02/89] feat(update): add update-check module for versions, state and release fetching --- electron/update-check.cjs | 216 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 electron/update-check.cjs diff --git a/electron/update-check.cjs b/electron/update-check.cjs new file mode 100644 index 0000000..c4d5e82 --- /dev/null +++ b/electron/update-check.cjs @@ -0,0 +1,216 @@ +// @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 {{ 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' + ? [{ 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, +}; From 84400bb6122a5a61842e63273c78df99c91f9b62 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 01:24:09 +0200 Subject: [PATCH 03/89] test(update): add coverage for updater strategies, throttle and apply flows --- electron/__tests__/updater.test.ts | 499 +++++++++++++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100644 electron/__tests__/updater.test.ts diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts new file mode 100644 index 0000000..d6f21d4 --- /dev/null +++ b/electron/__tests__/updater.test.ts @@ -0,0 +1,499 @@ +import { EventEmitter } from 'node:events'; +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'; + version?: string; +}; + +type Updater = { + applyUpdate: () => Promise; + checkForUpdates: (options?: { force?: boolean }) => Promise; + dismissUpdate: () => UpdateStatus; + getStatus: () => UpdateStatus; +}; + +type ReleaseAsset = { 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; + openPath?: (path: string) => Promise; + platform: string; + releaseUrl?: string; + strategy: 'download' | 'squirrel'; + }) => Updater; + pickReleaseAsset: ( + assets: ReadonlyArray, + options: { arch: string; linuxFlavor?: 'deb' | 'rpm' | null; platform: string }, + ) => ReleaseAsset | null; + resolveUpdateStrategy: (options: { + hasSquirrelUpdateExe: boolean; + platform: string; + }) => 'download' | '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(); + +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( + 'download', + ); + 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('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', + version: '1.9.3', + }); +}); + +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', 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' }); +}); + +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', version: '1.9.3' }); + + const forced = await updater.checkForUpdates({ force: true }); + expect(requests).toBe(1); + expect(forced).toEqual({ currentVersion: '1.9.2', phase: 'available', 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', 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 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' }); + 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' }); + expect(notifications).toEqual([{ currentVersion: '1.9.2', phase: 'idle' }]); + + 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', 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`, + 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', 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 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('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(); +}); From d98867b4aa9197da73a6727de54ac3a2740f4ef5 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 01:25:48 +0200 Subject: [PATCH 04/89] feat(update): add updater orchestration with Squirrel and download strategies --- electron/updater.cjs | 258 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 electron/updater.cjs diff --git a/electron/updater.cjs b/electron/updater.cjs new file mode 100644 index 0000000..6d7ebd2 --- /dev/null +++ b/electron/updater.cjs @@ -0,0 +1,258 @@ +// @ts-check + +const { writeFileSync } = require('node:fs'); +const { join } = require('node:path'); +const { + fetchLatestRelease, + getAvailableUpdate, + readUpdateState, + 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 {{ + * currentVersion: string; + * message?: string; + * phase: UpdatePhase; + * 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 {'download' | 'squirrel'} + */ +const resolveUpdateStrategy = ({ hasSquirrelUpdateExe, platform }) => + platform === 'darwin' || (platform === 'win32' && hasSquirrelUpdateExe) ? 'squirrel' : 'download'; + +/** + * @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) { + return find(({ name }) => name.endsWith(`.${linuxFlavor}`)); + } + + 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; + * openPath?: (path: string) => Promise; + * platform: string; + * releaseUrl?: string; + * strategy: 'download' | 'squirrel'; + * }} options + */ +const createUpdater = ({ + arch, + autoUpdater, + configDir, + currentVersion, + downloadDirectory, + isPackaged, + linuxFlavor, + log, + onStatusChange, + 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', version: update.version } + : { currentVersion, phase: 'idle' }; + }; + + /** @type {UpdateStatus} */ + let status = statusFromState(); + + /** @param {UpdateStatus} next */ + const setStatus = (next) => { + if ( + next.phase === status.phase && + next.version === status.version && + next.message === status.message + ) { + return status; + } + + status = next; + 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') { + setError('The update is not available for download yet. Try again later.', status.version); + } + }); + autoUpdater.on('error', (error) => { + if (status.phase === 'updating') { + setError(error instanceof Error ? error.message : String(error), status.version); + } + }); + } + + const checkForUpdates = async ({ force = false } = {}) => { + if (!isPackaged || status.phase === 'updating') { + return { ...status }; + } + + const state = readUpdateState(configDir); + if (!force && !shouldCheckForUpdates(state, Date.now())) { + return { ...setStatus(statusFromState()) }; + } + + try { + const release = await fetchLatestRelease(releaseUrl); + writeUpdateState( + { + lastCheckedAt: new Date().toISOString(), + latestVersion: release.version, + ...(state?.dismissedVersion ? { dismissedVersion: state.dismissedVersion } : {}), + }, + configDir, + ); + } catch (error) { + logError(`Update check failed: ${error instanceof Error ? error.message : String(error)}`); + } + + return { ...setStatus(statusFromState()) }; + }; + + const applySquirrelUpdate = () => { + if (!autoUpdater) { + return setError('The updater is unavailable in this build.', status.version); + } + + const version = status.version; + try { + autoUpdater.setFeedURL({ url: updateFeedUrl(platform, arch, currentVersion) }); + } catch (error) { + return setError(error instanceof Error ? error.message : String(error), version); + } + + const next = setStatus({ currentVersion, phase: 'updating', version }); + autoUpdater.checkForUpdates(); + return next; + }; + + const applyDownloadUpdate = async () => { + const version = status.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); + } + + setStatus({ currentVersion, phase: 'updating', version: release.version }); + + const response = await fetch(asset.url); + if (!response.ok) { + throw new Error(`Downloading the update failed with status ${response.status}.`); + } + + const path = join(downloadDirectory, asset.name); + writeFileSync(path, Buffer.from(await response.arrayBuffer())); + await openPath(path); + + return setStatus({ currentVersion, phase: 'installerReady', version: release.version }); + } catch (error) { + return setError(error instanceof Error ? error.message : String(error), version); + } + }; + + const applyUpdate = async () => { + if (status.phase !== 'available' && status.phase !== 'error') { + return { ...status }; + } + + return { + ...(strategy === 'squirrel' ? applySquirrelUpdate() : await applyDownloadUpdate()), + }; + }; + + const dismissUpdate = () => { + const state = readUpdateState(configDir); + if (state) { + writeUpdateState({ ...state, dismissedVersion: state.latestVersion }, configDir); + } + + return { ...setStatus(statusFromState()) }; + }; + + return { + applyUpdate, + checkForUpdates, + dismissUpdate, + getStatus: () => ({ ...status }), + }; +}; + +module.exports = { + createUpdater, + pickReleaseAsset, + resolveUpdateStrategy, +}; From 1fda1db8b7221454ac53659deb18631f28d3195b Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 01:28:04 +0200 Subject: [PATCH 05/89] test(update): add UpdateBanner rendering and interaction coverage --- core/__tests__/UpdateBanner.test.tsx | 140 +++++++++++++++++++++++++++ core/app/components/Panels.tsx | 11 +++ core/types.ts | 9 ++ 3 files changed, 160 insertions(+) create mode 100644 core/__tests__/UpdateBanner.test.tsx diff --git a/core/__tests__/UpdateBanner.test.tsx b/core/__tests__/UpdateBanner.test.tsx new file mode 100644 index 0000000..e7c4a3d --- /dev/null +++ b/core/__tests__/UpdateBanner.test.tsx @@ -0,0 +1,140 @@ +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { expect, test } from 'vite-plus/test'; +import { UpdateBanner, 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 renderBanner = 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 banner = (view: { container: HTMLElement }) => view.container.querySelector('.update-banner'); + +test('stays hidden while no update is available', async () => { + await using view = await renderBanner( + , + ); + + expect(banner(view)?.classList.contains('visible')).toBe(false); +}); + +test('shows the available version with an update action', async () => { + let applied = 0; + await using view = await renderBanner( + applied++} + onDismiss={noop} + onOpenReleasePage={noop} + status={status({ phase: 'available', version: '1.9.3' })} + />, + ); + + expect(banner(view)?.classList.contains('visible')).toBe(true); + expect(banner(view)?.textContent).toContain('1.9.3'); + + const update = view.container.querySelector('.update-banner-action'); + await act(async () => update?.click()); + expect(applied).toBe(1); +}); + +test('dismisses through the close button', async () => { + let dismissed = 0; + await using view = await renderBanner( + dismissed++} + onOpenReleasePage={noop} + status={status({ phase: 'available', version: '1.9.3' })} + />, + ); + + const dismiss = view.container.querySelector('.repository-change-dismiss'); + await act(async () => dismiss?.click()); + expect(dismissed).toBe(1); +}); + +test('shows progress while updating without actions', async () => { + await using view = await renderBanner( + , + ); + + expect(banner(view)?.classList.contains('visible')).toBe(true); + expect(banner(view)?.textContent).toContain('Updating'); + expect(view.container.querySelector('.update-banner-action')).toBeNull(); + expect(view.container.querySelector('.repository-change-dismiss')).toBeNull(); +}); + +test('tells the user to finish a handed-off install', async () => { + await using view = await renderBanner( + , + ); + + expect(banner(view)?.textContent).toContain('installer'); +}); + +test('offers retry and manual download after a failure', async () => { + let applied = 0; + let opened = 0; + await using view = await renderBanner( + applied++} + onDismiss={noop} + onOpenReleasePage={() => opened++} + status={status({ message: 'feed unreachable', phase: 'error', version: '1.9.3' })} + />, + ); + + expect(banner(view)?.textContent).toContain('failed'); + + const retry = view.container.querySelector('.update-banner-action'); + await act(async () => retry?.click()); + expect(applied).toBe(1); + + const manual = view.container.querySelector('.update-banner-manual'); + await act(async () => manual?.click()); + expect(opened).toBe(1); +}); diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index 48e4ce0..a605fe0 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -83,6 +83,17 @@ export function RepositoryChangeBanner({ ); } +export type { CodiffUpdateStatus as UpdateStatus } from '../../types.ts'; + +export function UpdateBanner(_props: { + onApply: () => void; + onDismiss: () => void; + onOpenReleasePage: () => void; + status: import('../../types.ts').CodiffUpdateStatus; +}) { + return null; +} + export function WalkthroughOutdatedBanner({ onDismiss, reason, diff --git a/core/types.ts b/core/types.ts index a06d32b..87c2f9f 100644 --- a/core/types.ts +++ b/core/types.ts @@ -762,6 +762,15 @@ 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; + version?: string; +}; + export type PullRequestReviewComment = { anchor?: 'file' | 'line'; body: string; From f57fda9cab0fc9ae6c1a45f042efd8a37d0ceceb Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 01:28:36 +0200 Subject: [PATCH 06/89] feat(update): render the update banner states and actions --- core/app/components/Panels.tsx | 51 ++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index a605fe0..123efe5 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -85,13 +85,60 @@ export function RepositoryChangeBanner({ export type { CodiffUpdateStatus as UpdateStatus } from '../../types.ts'; -export function UpdateBanner(_props: { +export function UpdateBanner({ + onApply, + onDismiss, + onOpenReleasePage, + status, +}: { onApply: () => void; onDismiss: () => void; onOpenReleasePage: () => void; status: import('../../types.ts').CodiffUpdateStatus; }) { - return null; + const { message, phase, version } = status; + const isVisible = phase !== 'idle'; + + return ( +
+ + {phase === 'available' ? ( + <> + {`Codiff ${version} is available,`} + + + ) : phase === 'updating' ? ( + {`Updating to Codiff ${version}…`} + ) : phase === 'installerReady' ? ( + The installer was downloaded and opened. Quit Codiff to finish updating. + ) : phase === 'error' ? ( + <> + {`Update failed${message ? `: ${message}` : '.'}`} + + or + + + ) : null} + + {phase === 'available' || phase === 'error' ? ( + + ) : null} +
+ ); } export function WalkthroughOutdatedBanner({ From 5ce273db724439b34378a2bcae1d84f2c507af9c Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 01:32:43 +0200 Subject: [PATCH 07/89] test(update): surface forced update-check failures to the caller --- electron/__tests__/updater.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index d6f21d4..345c54a 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -268,6 +268,31 @@ test('checkForUpdates swallows network failures and keeps the cached state', asy 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', + }); + 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; From 00e4bc300ee8317d442d46ee7ea74cc04e518268 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 01:32:57 +0200 Subject: [PATCH 08/89] fix(update): rethrow forced update-check failures after logging --- electron/updater.cjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/electron/updater.cjs b/electron/updater.cjs index 6d7ebd2..1240cf5 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -174,6 +174,9 @@ const createUpdater = ({ ); } catch (error) { logError(`Update check failed: ${error instanceof Error ? error.message : String(error)}`); + if (force) { + throw error; + } } return { ...setStatus(statusFromState()) }; From f879e0a59024e60462456938894a848d5c334049 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 01:41:04 +0200 Subject: [PATCH 09/89] feat(update): wire daily update checks, banner IPC and the updates menu item --- config/defaults.json | 1 + core/App.css | 64 ++++++++++++++++ core/App.tsx | 36 +++++++++ core/__tests__/App-render.test.tsx | 14 ++++ core/config/codiff-config.schema.json | 5 ++ core/config/types.ts | 1 + core/global.d.ts | 6 ++ electron/config.cjs | 4 + electron/main.cjs | 104 ++++++++++++++++++++++++++ electron/preload.cjs | 10 +++ 10 files changed, 245 insertions(+) diff --git a/config/defaults.json b/config/defaults.json index d7d4357..cc4f880 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.css b/core/App.css index 2294569..2c581dc 100644 --- a/core/App.css +++ b/core/App.css @@ -1120,6 +1120,70 @@ a.review-top-bar-source:focus-visible { font-weight: 700; } +.update-banner { + -webkit-app-region: no-drag; + align-items: center; + background: color-mix(in srgb, var(--viewed) 10%, var(--code-bg)); + border: 1px solid color-mix(in srgb, var(--viewed) 18%, transparent); + border-radius: 17px; + box-shadow: 0 16px 46px -30px color-mix(in srgb, var(--viewed) 52%, rgb(0 0 0)); + color: color-mix(in srgb, var(--viewed) 36%, var(--text)); + corner-shape: squircle; + display: inline-flex; + font-size: 13px; + font-weight: 600; + gap: 8px; + left: 50%; + line-height: 1.25; + max-width: min(620px, calc(100vw - 48px)); + padding: 8px 8px 8px 13px; + pointer-events: none; + position: fixed; + top: 18px; + transform: translate(-50%, -66px); + transition: + opacity 180ms ease, + transform 320ms cubic-bezier(0.2, 0.82, 0.22, 1); + z-index: 23; +} + +.update-banner.visible { + opacity: 1; + pointer-events: auto; + transform: translate(-50%, 0); +} + +.update-banner:not(.visible) { + opacity: 0; +} + +.update-banner-content { + align-items: center; + display: inline-flex; + flex-wrap: wrap; + gap: 5px; +} + +.update-banner button { + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + font: inherit; + padding: 0; +} + +.update-banner-action, +.update-banner-manual { + text-decoration: underline; + text-underline-offset: 2px; +} + +.update-banner-action:hover, +.update-banner-manual:hover { + text-decoration: none; +} + .diff-search-panel { -webkit-app-region: no-drag; align-items: center; diff --git a/core/App.tsx b/core/App.tsx index 18e4707..60de1dc 100644 --- a/core/App.tsx +++ b/core/App.tsx @@ -18,6 +18,7 @@ import { RepositoryChangeBanner, RepositoryLoadErrorPanel, ReviewSourceLoading, + UpdateBanner, 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; @@ -1839,6 +1861,20 @@ export default function App() { onDismiss={() => setWalkthroughFileError(null)} reason={walkthroughFileError?.reason ?? null} /> + {updateStatus ? ( + { + window.codiff.applyUpdate().then(setUpdateStatus, () => {}); + }} + onDismiss={() => { + window.codiff.dismissUpdate().then(setUpdateStatus, () => {}); + }} + onOpenReleasePage={() => { + window.codiff.openReleasePage().catch(() => {}); + }} + status={updateStatus} + /> + ) : null} ({ }); 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/config/codiff-config.schema.json b/core/config/codiff-config.schema.json index 4fc4920..4a56ca1 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 9f31ef7..afd3934 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 368661b..7619d9b 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/electron/config.cjs b/electron/config.cjs index 0807fb4..68bdd55 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 277fce7..2ea2df1 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,78 @@ 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, + 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({ @@ -1243,6 +1324,10 @@ 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), @@ -1314,6 +1399,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/preload.cjs b/electron/preload.cjs index e4288cf..ccb7900 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), From e432c60a9c8b96fb32e544d448908e24424455a0 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 01:42:30 +0200 Subject: [PATCH 10/89] test(cli): add coverage for the cached update notice --- bin/update-notice.js | 12 +++++ core/__tests__/codiff-update-notice.test.ts | 50 +++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 bin/update-notice.js create mode 100644 core/__tests__/codiff-update-notice.test.ts diff --git a/bin/update-notice.js b/bin/update-notice.js new file mode 100644 index 0000000..84de9d7 --- /dev/null +++ b/bin/update-notice.js @@ -0,0 +1,12 @@ +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +/** + * @param {{ configDir?: string; currentVersion: string }} options + * @returns {string | null} + */ +export function getUpdateNotice(options) { + void options; + return null; +} diff --git a/core/__tests__/codiff-update-notice.test.ts b/core/__tests__/codiff-update-notice.test.ts new file mode 100644 index 0000000..84e0a61 --- /dev/null +++ b/core/__tests__/codiff-update-notice.test.ts @@ -0,0 +1,50 @@ +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'); +}); + +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(); +}); From a815840859f371c4a97830ac07c81e60eed207bb Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 01:44:20 +0200 Subject: [PATCH 11/89] feat(cli): print a cached update notice on launch and --version --- bin/codiff.js | 10 ++++++++++ bin/update-notice.js | 12 +++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/bin/codiff.js b/bin/codiff.js index 51d3387..4975e69 100755 --- a/bin/codiff.js +++ b/bin/codiff.js @@ -17,6 +17,7 @@ import { } from './arguments.js'; import { completionShells, generateCompletionScript } from './completions.js'; import { waitForPlanResult } from './plan-result.js'; +import { getUpdateNotice } from './update-notice.js'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const require = createRequire(import.meta.url); @@ -115,6 +116,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 +301,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-notice.js b/bin/update-notice.js index 84de9d7..f92a069 100644 --- a/bin/update-notice.js +++ b/bin/update-notice.js @@ -1,12 +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(options) { - void options; - return 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}). Open the app to update.` + : null; } From 2586d322a5e4c12db8d5328805e94aad72bfc107 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 01:59:39 +0200 Subject: [PATCH 12/89] test(update): cover concurrent applies, openPath failures and dismissal races --- electron/__tests__/updater.test.ts | 160 +++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index 345c54a..c00beda 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -504,6 +504,166 @@ test('applyUpdate reports an error when no matching asset exists', async () => { 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`, 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('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`, 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' }); + + 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', 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('applyUpdate is a no-op unless an update is available', async () => { await using directory = await createTemporaryDirectory('codiff-updater-'); From 2588730f12dda3122d35bdc6e27005e4547da6ed Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 02:01:32 +0200 Subject: [PATCH 13/89] fix(update): serialize applies, surface openPath failures, protect dismissals --- electron/updater.cjs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/electron/updater.cjs b/electron/updater.cjs index 1240cf5..e565879 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -164,11 +164,15 @@ const createUpdater = ({ 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. A forced check is explicit + // user intent to see updates again, so it drops the dismissal. + const dismissedVersion = force ? undefined : readUpdateState(configDir)?.dismissedVersion; writeUpdateState( { lastCheckedAt: new Date().toISOString(), latestVersion: release.version, - ...(state?.dismissedVersion ? { dismissedVersion: state.dismissedVersion } : {}), + ...(dismissedVersion ? { dismissedVersion } : {}), }, configDir, ); @@ -201,6 +205,9 @@ const createUpdater = ({ 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); @@ -210,8 +217,6 @@ const createUpdater = ({ return setError('No download is available for this platform.', version); } - setStatus({ currentVersion, phase: 'updating', version: release.version }); - const response = await fetch(asset.url); if (!response.ok) { throw new Error(`Downloading the update failed with status ${response.status}.`); @@ -219,7 +224,10 @@ const createUpdater = ({ const path = join(downloadDirectory, asset.name); writeFileSync(path, Buffer.from(await response.arrayBuffer())); - await openPath(path); + const openError = await openPath(path); + if (openError) { + throw new Error(openError); + } return setStatus({ currentVersion, phase: 'installerReady', version: release.version }); } catch (error) { From 6c710d93ec120985518d63557853ba2de0cbca4a Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 02:08:51 +0200 Subject: [PATCH 14/89] test(update): cover out-of-order check completions and mid-flight dismissals --- electron/__tests__/updater.test.ts | 95 ++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index c00beda..810e0e0 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -90,6 +90,16 @@ const writeState = async ( const recentCheck = () => new Date().toISOString(); +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', @@ -664,6 +674,91 @@ test('a forced check clears the dismissal and resurfaces the update', async () = expect(persisted.dismissedVersion).toBeUndefined(); }); +test('a stale check completion cannot clobber a newer 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 scheduled = updater.checkForUpdates(); + await waitFor(() => pending.length === 1); + const forced = updater.checkForUpdates({ force: true }); + await waitFor(() => pending.length === 2); + + pending[1]('1.9.4'); + expect(await forced).toEqual({ currentVersion: '1.9.2', phase: 'available', version: '1.9.4' }); + + pending[0]('1.9.3'); + await scheduled; + + expect(updater.getStatus()).toEqual({ + currentVersion: '1.9.2', + phase: 'available', + 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('applyUpdate is a no-op unless an update is available', async () => { await using directory = await createTemporaryDirectory('codiff-updater-'); From 788db17a49d922deafb3e5ff0a6abd5f4d82f09d Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 02:10:38 +0200 Subject: [PATCH 15/89] fix(update): discard stale check completions and keep mid-flight dismissals --- electron/updater.cjs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/electron/updater.cjs b/electron/updater.cjs index e565879..2734e41 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -152,6 +152,8 @@ const createUpdater = ({ }); } + let checkSequence = 0; + const checkForUpdates = async ({ force = false } = {}) => { if (!isPackaged || status.phase === 'updating') { return { ...status }; @@ -162,12 +164,23 @@ const createUpdater = ({ return { ...setStatus(statusFromState()) }; } + const checkId = ++checkSequence; + const dismissedBefore = state?.dismissedVersion; + try { const release = await fetchLatestRelease(releaseUrl); + + // A newer check started while this one was in flight; its result wins. + if (checkId !== checkSequence) { + return { ...status }; + } + // Re-read after the network round trip: a dismissal may have been - // persisted while the request was in flight. A forced check is explicit - // user intent to see updates again, so it drops the dismissal. - const dismissedVersion = force ? undefined : readUpdateState(configDir)?.dismissedVersion; + // 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(), From a8290dec6663b7c1a95dd9c38e7cb2f2148b1d6c Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 02:15:39 +0200 Subject: [PATCH 16/89] test(update): cover check completions during active applies and stale failures --- electron/__tests__/updater.test.ts | 92 ++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index 810e0e0..99bc7ec 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -759,6 +759,98 @@ test('a forced check keeps a dismissal made while it was in flight', async () => 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 stale failed check does not reject after a newer check succeeded', 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 }); + await waitFor(() => pending.length === 2); + + pending[1]({ version: '1.9.4' }); + await expect(second).resolves.toEqual({ + currentVersion: '1.9.2', + phase: 'available', + version: '1.9.4', + }); + + pending[0]({ status: 500 }); + await expect(first).resolves.toEqual({ + currentVersion: '1.9.2', + phase: 'available', + version: '1.9.4', + }); + expect(log).toEqual([]); +}); + test('applyUpdate is a no-op unless an update is available', async () => { await using directory = await createTemporaryDirectory('codiff-updater-'); From b1d0b6ec0362b556c8142aa93a340298fe56d804 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 02:17:22 +0200 Subject: [PATCH 17/89] fix(update): keep active applies and drop stale failures on check completion --- electron/updater.cjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/electron/updater.cjs b/electron/updater.cjs index 2734e41..2c7b6f9 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -190,12 +190,24 @@ const createUpdater = ({ configDir, ); } catch (error) { + // A newer check already completed; its outcome stands and a stale + // failure must not surface an error for it. + if (checkId !== checkSequence) { + return { ...status }; + } + logError(`Update check failed: ${error instanceof Error ? error.message : String(error)}`); if (force) { throw error; } } + // An apply started while the check was in flight; do not yank its phase + // out from under the auto-updater events or the handed-off installer. + if (status.phase === 'updating' || status.phase === 'installerReady') { + return { ...status }; + } + return { ...setStatus(statusFromState()) }; }; From 77d0c1617377786b8d2c596f2350e09e2e279808 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 02:22:39 +0200 Subject: [PATCH 18/89] test(update): keep apply failures visible across check completions --- electron/__tests__/updater.test.ts | 39 ++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index 99bc7ec..76a39d4 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -851,6 +851,45 @@ test('a stale failed check does not reject after a newer check succeeded', async expect(log).toEqual([]); }); +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('applyUpdate is a no-op unless an update is available', async () => { await using directory = await createTemporaryDirectory('codiff-updater-'); From 89bfebef0c09bfaa049ff634a029757e54166daf Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 02:24:31 +0200 Subject: [PATCH 19/89] fix(update): guard check completions with a status generation counter --- electron/updater.cjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/electron/updater.cjs b/electron/updater.cjs index 2c7b6f9..832a599 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -107,6 +107,8 @@ const createUpdater = ({ /** @type {UpdateStatus} */ let status = statusFromState(); + let statusGeneration = 0; + /** @param {UpdateStatus} next */ const setStatus = (next) => { if ( @@ -117,6 +119,7 @@ const createUpdater = ({ return status; } + statusGeneration++; status = next; onStatusChange?.({ ...status }); return status; @@ -165,6 +168,7 @@ const createUpdater = ({ } const checkId = ++checkSequence; + const generationAtStart = statusGeneration; const dismissedBefore = state?.dismissedVersion; try { @@ -202,9 +206,10 @@ const createUpdater = ({ } } - // An apply started while the check was in flight; do not yank its phase - // out from under the auto-updater events or the handed-off installer. - if (status.phase === 'updating' || status.phase === 'installerReady') { + // The status moved while the check was in flight (an apply started, + // failed, or handed off; or the user dismissed). That transition is newer + // information than this check; do not overwrite it. + if (statusGeneration !== generationAtStart) { return { ...status }; } From c3106589eebe18b276be65f1c240e9ca8d3088c3 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 02:29:09 +0200 Subject: [PATCH 20/89] test(update): keep identical retry failures visible across check completions --- electron/__tests__/updater.test.ts | 43 ++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index 76a39d4..ed737c5 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -890,6 +890,49 @@ test('a check completion does not erase an apply failure', async () => { 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('applyUpdate is a no-op unless an update is available', async () => { await using directory = await createTemporaryDirectory('codiff-updater-'); From bcf1a9078c2b0e64cde6e1957443b933e0b96d36 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 02:30:49 +0200 Subject: [PATCH 21/89] fix(update): enter the updating phase before configuring the Squirrel feed --- electron/updater.cjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/electron/updater.cjs b/electron/updater.cjs index 832a599..93d8f33 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -222,13 +222,16 @@ const createUpdater = ({ } 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); } - const next = setStatus({ currentVersion, phase: 'updating', version }); autoUpdater.checkForUpdates(); return next; }; From a87015f8bfed4c5f22021fb91e9f1025570d9ac8 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 02:37:26 +0200 Subject: [PATCH 22/89] test(update): specify serialized checks with caller-owned failures --- electron/__tests__/updater.test.ts | 69 ++++++++++++++++++++++++------ 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index ed737c5..a68707f 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -674,7 +674,7 @@ test('a forced check clears the dismissal and resurfaces the update', async () = expect(persisted.dismissedVersion).toBeUndefined(); }); -test('a stale check completion cannot clobber a newer result', async () => { +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) => { @@ -702,20 +702,21 @@ test('a stale check completion cannot clobber a newer result', async () => { const scheduled = updater.checkForUpdates(); await waitFor(() => pending.length === 1); const forced = updater.checkForUpdates({ force: true }); - await waitFor(() => pending.length === 2); - pending[1]('1.9.4'); - expect(await forced).toEqual({ currentVersion: '1.9.2', phase: 'available', version: '1.9.4' }); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 50)); + expect(pending.length).toBe(1); pending[0]('1.9.3'); - await scheduled; - - expect(updater.getStatus()).toEqual({ + expect(await scheduled).toEqual({ currentVersion: '1.9.2', phase: 'available', - version: '1.9.4', + version: '1.9.3', }); + await waitFor(() => pending.length === 2); + pending[1]('1.9.4'); + expect(await forced).toEqual({ currentVersion: '1.9.2', phase: 'available', version: '1.9.4' }); + const persisted = JSON.parse( await readFile(join(directory.path, 'update-state.json'), 'utf8'), ) as { latestVersion: string }; @@ -798,7 +799,7 @@ test('a check completion does not cancel an active Squirrel update', async () => expect(autoUpdater.quitAndInstallCalls).toBe(1); }); -test('a stale failed check does not reject after a newer check succeeded', async () => { +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) => { @@ -833,22 +834,64 @@ test('a stale failed check does not reject after a newer check succeeded', async const first = updater.checkForUpdates({ force: true }); await waitFor(() => pending.length === 1); const second = updater.checkForUpdates({ force: true }); - await waitFor(() => pending.length === 2); + 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', version: '1.9.4', }); + expect(log.length).toBe(1); +}); - pending[0]({ status: 500 }); - await expect(first).resolves.toEqual({ +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', version: '1.9.4' }); + expect(await scheduled).toEqual({ currentVersion: '1.9.2', phase: 'available', version: '1.9.4', }); - expect(log).toEqual([]); + 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 () => { From 43e2937ceaaa515e22f6daf973289924219a3bd2 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 02:39:14 +0200 Subject: [PATCH 23/89] fix(update): serialize update checks behind a single in-flight request --- electron/updater.cjs | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/electron/updater.cjs b/electron/updater.cjs index 93d8f33..308251a 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -155,30 +155,18 @@ const createUpdater = ({ }); } - let checkSequence = 0; - - const checkForUpdates = async ({ force = false } = {}) => { - if (!isPackaged || status.phase === 'updating') { - return { ...status }; - } - + const performCheck = async (force) => { const state = readUpdateState(configDir); if (!force && !shouldCheckForUpdates(state, Date.now())) { return { ...setStatus(statusFromState()) }; } - const checkId = ++checkSequence; const generationAtStart = statusGeneration; const dismissedBefore = state?.dismissedVersion; try { const release = await fetchLatestRelease(releaseUrl); - // A newer check started while this one was in flight; its result wins. - if (checkId !== checkSequence) { - return { ...status }; - } - // 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 @@ -194,12 +182,6 @@ const createUpdater = ({ configDir, ); } catch (error) { - // A newer check already completed; its outcome stands and a stale - // failure must not surface an error for it. - if (checkId !== checkSequence) { - return { ...status }; - } - logError(`Update check failed: ${error instanceof Error ? error.message : String(error)}`); if (force) { throw error; @@ -216,6 +198,27 @@ const createUpdater = ({ 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 } = {}) => { + if (!isPackaged || status.phase === 'updating') { + return Promise.resolve({ ...status }); + } + + const current = pendingCheck.then(() => performCheck(force)); + pendingCheck = current.then( + () => undefined, + () => undefined, + ); + return current; + }; + const applySquirrelUpdate = () => { if (!autoUpdater) { return setError('The updater is unavailable in this build.', status.version); From f4e3a1fd02bb933d44cba50abace7224da6cada9 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 02:45:06 +0200 Subject: [PATCH 24/89] test(update): pin queued checks to their enqueue-time context --- electron/__tests__/updater.test.ts | 85 ++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index a68707f..1e8471f 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -976,6 +976,91 @@ test('a retry that fails identically still outlives a completing check', async ( 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' }); + + 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('applyUpdate is a no-op unless an update is available', async () => { await using directory = await createTemporaryDirectory('codiff-updater-'); From 1f3819e2bc28482ffd521c33baaf34758c00ddd8 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 02:48:00 +0200 Subject: [PATCH 25/89] fix(update): pin checks to enqueue-time action generation and dismissal state --- electron/updater.cjs | 42 +++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/electron/updater.cjs b/electron/updater.cjs index 308251a..f299301 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -107,7 +107,12 @@ const createUpdater = ({ /** @type {UpdateStatus} */ let status = statusFromState(); - let statusGeneration = 0; + // 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) => { @@ -119,7 +124,6 @@ const createUpdater = ({ return status; } - statusGeneration++; status = next; onStatusChange?.({ ...status }); return status; @@ -145,25 +149,31 @@ const createUpdater = ({ }); 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); } }); } - const performCheck = async (force) => { + /** + * @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())) { - return { ...setStatus(statusFromState()) }; + return actionGeneration !== generationAtStart + ? { ...status } + : { ...setStatus(statusFromState()) }; } - const generationAtStart = statusGeneration; - const dismissedBefore = state?.dismissedVersion; - try { const release = await fetchLatestRelease(releaseUrl); @@ -188,10 +198,10 @@ const createUpdater = ({ } } - // The status moved while the check was in flight (an apply started, - // failed, or handed off; or the user dismissed). That transition is newer + // 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 (statusGeneration !== generationAtStart) { + if (actionGeneration !== generationAtStart) { return { ...status }; } @@ -211,7 +221,15 @@ const createUpdater = ({ return Promise.resolve({ ...status }); } - const current = pendingCheck.then(() => performCheck(force)); + // 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, @@ -276,12 +294,14 @@ const createUpdater = ({ return { ...status }; } + actionGeneration++; return { ...(strategy === 'squirrel' ? applySquirrelUpdate() : await applyDownloadUpdate()), }; }; const dismissUpdate = () => { + actionGeneration++; const state = readUpdateState(configDir); if (state) { writeUpdateState({ ...state, dismissedVersion: state.latestVersion }, configDir); From 160f892c13e586e1a71cb0c5c7943b7af254ee17 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 02:54:08 +0200 Subject: [PATCH 26/89] test(update): keep terminal apply outcomes through throttled checks --- electron/__tests__/updater.test.ts | 74 ++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index 1e8471f..7a4ce00 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -1061,6 +1061,80 @@ test('an apply failure after a check was queued survives its completion', async 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`, 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('applyUpdate is a no-op unless an update is available', async () => { await using directory = await createTemporaryDirectory('codiff-updater-'); From 4cc6b7f9068022db903a66c18d7cb2daae6b7d0d Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 02:55:50 +0200 Subject: [PATCH 27/89] fix(update): treat throttled checks as read-only and installerReady as terminal --- electron/updater.cjs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/electron/updater.cjs b/electron/updater.cjs index f299301..1b1d55e 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -169,9 +169,8 @@ const createUpdater = ({ const performCheck = async (force, generationAtStart, dismissedBefore) => { const state = readUpdateState(configDir); if (!force && !shouldCheckForUpdates(state, Date.now())) { - return actionGeneration !== generationAtStart - ? { ...status } - : { ...setStatus(statusFromState()) }; + // A throttled check brings no new information; never move the status. + return { ...status }; } try { @@ -217,7 +216,9 @@ const createUpdater = ({ let pendingCheck = Promise.resolve(); const checkForUpdates = ({ force = false } = {}) => { - if (!isPackaged || status.phase === 'updating') { + // 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 }); } From fd237a76759555c50da3452b4fee308fff57f5af Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 09:16:11 +0200 Subject: [PATCH 28/89] test(update): cover applyLatest and the apply-update launch flag --- electron/__tests__/command-line.test.ts | 6 +++ electron/__tests__/updater.test.ts | 53 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/electron/__tests__/command-line.test.ts b/electron/__tests__/command-line.test.ts index 8849b3d..491ba18 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__/updater.test.ts b/electron/__tests__/updater.test.ts index 7a4ce00..be0a9a9 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -17,6 +17,7 @@ type UpdateStatus = { }; type Updater = { + applyLatest: () => Promise; applyUpdate: () => Promise; checkForUpdates: (options?: { force?: boolean }) => Promise; dismissUpdate: () => UpdateStatus; @@ -1135,6 +1136,58 @@ test('a throttled check does not erase an apply failure', async () => { 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', 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' }); + expect(autoUpdater.feedURL).toBeNull(); + expect(autoUpdater.checkForUpdatesCalls).toBe(0); +}); + test('applyUpdate is a no-op unless an update is available', async () => { await using directory = await createTemporaryDirectory('codiff-updater-'); From 04bc1a1e09e2754cd75bf748bb1ecd44c81fcc7c Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 09:16:48 +0200 Subject: [PATCH 29/89] feat(update): add applyLatest and the apply-update launch flag --- core/types.ts | 1 + electron/main/command-line.cjs | 4 ++++ electron/updater.cjs | 6 ++++++ 3 files changed, 11 insertions(+) diff --git a/core/types.ts b/core/types.ts index 87c2f9f..8be2687 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; diff --git a/electron/main/command-line.cjs b/electron/main/command-line.cjs index 7a8760a..b2406b1 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/updater.cjs b/electron/updater.cjs index 1b1d55e..86b4102 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -301,6 +301,11 @@ const createUpdater = ({ }; }; + const applyLatest = async () => { + const checked = await checkForUpdates({ force: true }); + return checked.phase === 'available' ? applyUpdate() : checked; + }; + const dismissUpdate = () => { actionGeneration++; const state = readUpdateState(configDir); @@ -312,6 +317,7 @@ const createUpdater = ({ }; return { + applyLatest, applyUpdate, checkForUpdates, dismissUpdate, From 1db42a5c9f1e047bfd34b60d5df33109e11959f6 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 09:17:53 +0200 Subject: [PATCH 30/89] test(cli): cover the codiff update command decision and runner --- bin/update-command.js | 30 +++ core/__tests__/codiff-update-command.test.ts | 194 +++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 bin/update-command.js create mode 100644 core/__tests__/codiff-update-command.test.ts diff --git a/bin/update-command.js b/bin/update-command.js new file mode 100644 index 0000000..75715ee --- /dev/null +++ b/bin/update-command.js @@ -0,0 +1,30 @@ +/** + * @param {{ + * brewOwnsCask: () => boolean; + * currentVersion: string; + * isSourceCheckout: boolean; + * latestVersion: string; + * }} options + * @returns {{ kind: 'up-to-date' } | { kind: 'brew-upgrade' | 'open-app' | 'source-checkout'; version: string }} + */ +export function resolveUpdateAction(options) { + void options; + return { kind: 'up-to-date' }; +} + +/** + * @param {{ + * brewOwnsCask: () => boolean; + * currentVersion: string; + * isSourceCheckout: boolean; + * log: (line: string) => void; + * openApp: (() => void) | null; + * releaseUrl?: string; + * runBrewUpgrade: () => number; + * }} options + * @returns {Promise} + */ +export async function runUpdateCommand(options) { + void options; + return 0; +} diff --git a/core/__tests__/codiff-update-command.test.ts b/core/__tests__/codiff-update-command.test.ts new file mode 100644 index 0000000..a14c263 --- /dev/null +++ b/core/__tests__/codiff-update-command.test.ts @@ -0,0 +1,194 @@ +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}/` }; +}; + +const brewOwnsCask = () => { + throw new Error('Must not probe brew when up to date.'); +}; + +test('resolveUpdateAction reports up to date for equal or older releases', () => { + expect( + resolveUpdateAction({ + brewOwnsCask, + currentVersion: '1.9.2', + isSourceCheckout: false, + latestVersion: '1.9.2', + }), + ).toEqual({ kind: 'up-to-date' }); + expect( + resolveUpdateAction({ + brewOwnsCask, + 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({ + brewOwnsCask: () => true, + currentVersion: '1.9.2', + isSourceCheckout: true, + latestVersion: '1.9.3', + }), + ).toEqual({ kind: 'source-checkout', version: '1.9.3' }); +}); + +test('resolveUpdateAction upgrades brew-owned installs through brew', () => { + expect( + resolveUpdateAction({ + brewOwnsCask: () => true, + currentVersion: '1.9.2', + isSourceCheckout: false, + latestVersion: '1.9.3', + }), + ).toEqual({ kind: 'brew-upgrade', version: '1.9.3' }); +}); + +test('resolveUpdateAction hands other installs to the app', () => { + expect( + resolveUpdateAction({ + brewOwnsCask: () => false, + 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({ + brewOwnsCask: () => false, + currentVersion: '1.9.2', + isSourceCheckout: false, + log: (line) => lines.push(line), + openApp: () => { + throw new Error('Must not open the app.'); + }, + releaseUrl: url, + runBrewUpgrade: () => { + throw new Error('Must not run brew.'); + }, + }); + + expect(exitCode).toBe(0); + expect(lines.join('\n')).toContain('up to date'); +}); + +test('runUpdateCommand runs brew for brew-owned installs and returns its exit code', async () => { + const { disposable: _server, url } = await startReleaseServer('1.9.3'); + let upgrades = 0; + + const exitCode = await runUpdateCommand({ + brewOwnsCask: () => true, + currentVersion: '1.9.2', + isSourceCheckout: false, + log: () => {}, + openApp: () => {}, + releaseUrl: url, + runBrewUpgrade: () => { + upgrades++; + return 0; + }, + }); + + expect(exitCode).toBe(0); + expect(upgrades).toBe(1); +}); + +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({ + brewOwnsCask: () => false, + currentVersion: '1.9.2', + isSourceCheckout: false, + log: (line) => lines.push(line), + openApp: () => { + opened++; + }, + releaseUrl: url, + runBrewUpgrade: () => 1, + }); + + 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({ + brewOwnsCask: () => false, + currentVersion: '1.9.2', + isSourceCheckout: false, + log: (line) => lines.push(line), + openApp: null, + releaseUrl: url, + runBrewUpgrade: () => 1, + }); + + 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({ + brewOwnsCask: () => true, + currentVersion: '1.9.2', + isSourceCheckout: true, + log: (line) => lines.push(line), + openApp: () => { + throw new Error('Must not open the app.'); + }, + releaseUrl: url, + runBrewUpgrade: () => { + throw new Error('Must not run brew.'); + }, + }); + + 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({ + brewOwnsCask: () => false, + currentVersion: '1.9.2', + isSourceCheckout: false, + log: (line) => lines.push(line), + openApp: () => {}, + releaseUrl: url, + runBrewUpgrade: () => 1, + }); + + expect(exitCode).toBe(1); + expect(lines.join('\n')).toContain('could not check'); +}); From b289bb974d9e1672dfa84d72c1e652708db0a335 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 09:18:23 +0200 Subject: [PATCH 31/89] feat(cli): implement the codiff update decision and runner --- bin/update-command.js | 88 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 82 insertions(+), 6 deletions(-) diff --git a/bin/update-command.js b/bin/update-command.js index 75715ee..6fdc1e3 100644 --- a/bin/update-command.js +++ b/bin/update-command.js @@ -1,4 +1,15 @@ +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. + * * @param {{ * brewOwnsCask: () => boolean; * currentVersion: string; @@ -7,12 +18,28 @@ * }} options * @returns {{ kind: 'up-to-date' } | { kind: 'brew-upgrade' | 'open-app' | 'source-checkout'; version: string }} */ -export function resolveUpdateAction(options) { - void options; - return { kind: 'up-to-date' }; +export function resolveUpdateAction({ + brewOwnsCask, + currentVersion, + isSourceCheckout, + latestVersion, +}) { + if (!isNewerVersion(latestVersion, currentVersion)) { + return { kind: 'up-to-date' }; + } + + if (isSourceCheckout) { + return { kind: 'source-checkout', version: latestVersion }; + } + + return brewOwnsCask() + ? { kind: 'brew-upgrade', version: latestVersion } + : { kind: 'open-app', version: latestVersion }; } /** + * Check GitHub Releases and perform the platform-appropriate update. + * * @param {{ * brewOwnsCask: () => boolean; * currentVersion: string; @@ -24,7 +51,56 @@ export function resolveUpdateAction(options) { * }} options * @returns {Promise} */ -export async function runUpdateCommand(options) { - void options; - return 0; +export async function runUpdateCommand({ + brewOwnsCask, + currentVersion, + isSourceCheckout, + log, + openApp, + releaseUrl, + runBrewUpgrade, +}) { + 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({ + brewOwnsCask, + 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 'brew-upgrade': + log(`Updating Codiff v${currentVersion} -> v${action.version} via Homebrew…`); + return runBrewUpgrade(); + 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; + } } From 301e336d0cd4aec2df27d42ea1c304164f7d1b18 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 09:21:19 +0200 Subject: [PATCH 32/89] feat(cli): add the codiff update command across dev, brew and app installs --- bin/arguments.js | 1 + bin/codiff-app | 35 ++++++++++++++++++++++++----------- bin/codiff.js | 43 ++++++++++++++++++++++++++++++++++++++++++- electron/main.cjs | 6 ++++++ 4 files changed, 73 insertions(+), 12 deletions(-) diff --git a/bin/arguments.js b/bin/arguments.js index 1ade38c..9a68171 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 900ddb6..c72e464 100755 --- a/bin/codiff-app +++ b/bin/codiff-app @@ -30,6 +30,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 +68,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 diff --git a/bin/codiff.js b/bin/codiff.js index 4975e69..8ee78a7 100755 --- a/bin/codiff.js +++ b/bin/codiff.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import http from 'node:http'; import https from 'node:https'; @@ -17,6 +17,7 @@ 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)), '..'); @@ -106,7 +107,47 @@ 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({ + brewOwnsCask: () => { + try { + execFileSync('brew', ['list', '--cask', 'codiff'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } + }, + 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, + runBrewUpgrade: () => { + try { + execFileSync('brew', ['upgrade', '--cask', 'codiff'], { stdio: 'inherit' }); + return 0; + } catch (error) { + const status = /** @type {{ status?: unknown }} */ (error)?.status; + return typeof status === 'number' ? status : 1; + } + }, + }); +}; + 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) { diff --git a/electron/main.cjs b/electron/main.cjs index 2ea2df1..454d49e 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1296,6 +1296,9 @@ if (squirrelStartup || !lock) { getInitialRepositoryPath(launchPath, launchOptions, config.settings.lastRepositoryPath), launchOptions, ); + if (launchOptions.applyUpdate) { + void updater?.applyLatest().catch(() => {}); + } }); app.on('ready', () => { @@ -1333,6 +1336,9 @@ if (squirrelStartup || !lock) { getInitialRepositoryPath(getLaunchPath(), launchOptions, config.settings.lastRepositoryPath), launchOptions, ); + if (launchOptions.applyUpdate) { + void updater?.applyLatest().catch(() => {}); + } watchConfig((nextConfig) => { config = { From ee33cb3c408f4a5520dbe5d3386a2077c45d7c5e Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 09:34:29 +0200 Subject: [PATCH 33/89] test(update): surface applyLatest check failures and version-less progress --- core/__tests__/UpdateBanner.test.tsx | 14 ++++++++++++++ electron/__tests__/updater.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/core/__tests__/UpdateBanner.test.tsx b/core/__tests__/UpdateBanner.test.tsx index e7c4a3d..4e7cc51 100644 --- a/core/__tests__/UpdateBanner.test.tsx +++ b/core/__tests__/UpdateBanner.test.tsx @@ -103,6 +103,20 @@ test('shows progress while updating without actions', async () => { expect(view.container.querySelector('.repository-change-dismiss')).toBeNull(); }); +test('shows progress without a version after an error retry', async () => { + await using view = await renderBanner( + , + ); + + expect(banner(view)?.textContent).toContain('Updating Codiff'); + expect(banner(view)?.textContent).not.toContain('undefined'); +}); + test('tells the user to finish a handed-off install', async () => { await using view = await renderBanner( { 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('applyUpdate is a no-op unless an update is available', async () => { await using directory = await createTemporaryDirectory('codiff-updater-'); From cab92955931c3634aeda860b0a743eba9397309a Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 09:36:29 +0200 Subject: [PATCH 34/89] fix(update): turn applyLatest check failures into the error banner state --- core/app/components/Panels.tsx | 2 +- electron/updater.cjs | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index 123efe5..492ac91 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -110,7 +110,7 @@ export function UpdateBanner({ ) : phase === 'updating' ? ( - {`Updating to Codiff ${version}…`} + {version ? `Updating to Codiff ${version}…` : 'Updating Codiff…'} ) : phase === 'installerReady' ? ( The installer was downloaded and opened. Quit Codiff to finish updating. ) : phase === 'error' ? ( diff --git a/electron/updater.cjs b/electron/updater.cjs index 86b4102..39787f2 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -302,7 +302,16 @@ const createUpdater = ({ }; const applyLatest = async () => { - const checked = await checkForUpdates({ force: true }); + let checked; + try { + checked = await checkForUpdates({ force: true }); + } catch (error) { + // 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)) }; + } + return checked.phase === 'available' ? applyUpdate() : checked; }; From 81bee42812f34377fbca339dc68a242433ac1509 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 09:41:53 +0200 Subject: [PATCH 35/89] test(update): keep applies alive when a concurrent applyLatest check fails --- electron/__tests__/updater.test.ts | 40 ++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index 3b2b822..39106bc 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -1216,6 +1216,46 @@ test('applyLatest surfaces a failed check as an error status', async () => { 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('applyUpdate is a no-op unless an update is available', async () => { await using directory = await createTemporaryDirectory('codiff-updater-'); From 7913c30aa9e236b211a985ce168d344a8a3fc27f Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 09:43:26 +0200 Subject: [PATCH 36/89] fix(update): let mid-flight actions outlive a failed applyLatest check --- electron/updater.cjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/electron/updater.cjs b/electron/updater.cjs index 39787f2..c3dfaee 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -302,10 +302,17 @@ const createUpdater = ({ }; const applyLatest = async () => { + const generationAtStart = actionGeneration; let checked; try { checked = await checkForUpdates({ force: true }); } catch (error) { + // An apply or dismissal that started while the check was pending is + // newer information than this failure; leave its status alone. + if (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++; From 99e0f223142e34353fa98762c078b0f6dd32629c Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 09:48:34 +0200 Subject: [PATCH 37/89] test(update): let a newer applyLatest outrank an older failed one --- electron/__tests__/updater.test.ts | 48 ++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index 39106bc..b193165 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -1256,6 +1256,54 @@ test('a failed applyLatest check does not cancel an apply started meanwhile', as 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', version: '1.9.4' }); + expect(updater.getStatus().phase).toBe('updating'); + expect(autoUpdater.checkForUpdatesCalls).toBe(1); +}); + test('applyUpdate is a no-op unless an update is available', async () => { await using directory = await createTemporaryDirectory('codiff-updater-'); From 56f439f771766ce4d2540925324681ed99e61d27 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 09:50:19 +0200 Subject: [PATCH 38/89] fix(update): sequence applyLatest so newer requests own the outcome --- electron/updater.cjs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/electron/updater.cjs b/electron/updater.cjs index c3dfaee..e8a4fd9 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -301,15 +301,19 @@ const createUpdater = ({ }; }; + 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 is - // newer information than this failure; leave its status alone. - if (actionGeneration !== generationAtStart) { + // 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 }; } From 3926b23ef9425ff1c013b14d1d015293ddf1c576 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 09:56:12 +0200 Subject: [PATCH 39/89] test(update): give the newest applyLatest ownership of successful applies --- electron/__tests__/updater.test.ts | 46 ++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index b193165..cf3138b 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -1304,6 +1304,52 @@ test('an older failed applyLatest defers to a newer successful one', async () => 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', version: '1.9.4' }); + expect(updater.getStatus()).toEqual({ + currentVersion: '1.9.2', + phase: 'updating', + 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-'); From 6938699dad698315d8e90e4d63d84cd311a9b1c1 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 09:57:56 +0200 Subject: [PATCH 40/89] fix(update): defer older successful applyLatest requests to newer ones --- electron/updater.cjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/electron/updater.cjs b/electron/updater.cjs index e8a4fd9..265bcce 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -323,6 +323,12 @@ const createUpdater = ({ 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; }; From 1306e4ffd00d7971481a79375c0226581bac795a Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 10:20:54 +0200 Subject: [PATCH 41/89] test(cli): expect the installed wrapper to print the cached update notice --- core/__tests__/codiff-app-notice.test.ts | 78 ++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 core/__tests__/codiff-app-notice.test.ts diff --git a/core/__tests__/codiff-app-notice.test.ts b/core/__tests__/codiff-app-notice.test.ts new file mode 100644 index 0000000..cd5437c --- /dev/null +++ b/core/__tests__/codiff-app-notice.test.ts @@ -0,0 +1,78 @@ +import { execFile } from 'node:child_process'; +import { 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 }, +) => { + await mkdir(join(home, '.codiff'), { recursive: true }); + await writeFile(join(home, '.codiff', 'update-state.json'), JSON.stringify(state, null, 2)); +}; + +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(''); +}); From e4e05538cc79a793797babb4e8c9a2fdd152838e Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 10:22:05 +0200 Subject: [PATCH 42/89] test(cli): expect the update notice to mention codiff update --- core/__tests__/codiff-update-notice.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/core/__tests__/codiff-update-notice.test.ts b/core/__tests__/codiff-update-notice.test.ts index 84e0a61..94bc7bd 100644 --- a/core/__tests__/codiff-update-notice.test.ts +++ b/core/__tests__/codiff-update-notice.test.ts @@ -25,6 +25,7 @@ test('announces a newer cached version with both versions', async () => { 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 () => { From 485958e2b92d96c80283736dd1c6ee184d271040 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 10:23:35 +0200 Subject: [PATCH 43/89] fix(cli): print the cached update notice from the installed wrapper --- bin/codiff-app | 42 ++++++++++++++++++++++++++++++++++++++++++ bin/update-notice.js | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/bin/codiff-app b/bin/codiff-app index c72e464..cccbb20 100755 --- a/bin/codiff-app +++ b/bin/codiff-app @@ -20,6 +20,45 @@ 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 + 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 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. +print_update_notice() { + state_file="$HOME/.codiff/update-state.json" + [ -f "$state_file" ] || return 0 + latest="$(sed -n 's/.*"latestVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$state_file" | head -n 1)" + dismissed="$(sed -n 's/.*"dismissedVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$state_file" | head -n 1)" + [ -n "$latest" ] || return 0 + [ "$latest" = "$dismissed" ] && return 0 + current="$(codiff_version)" + is_newer_version "$latest" "$current" || return 0 + 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')" @@ -272,6 +311,7 @@ for arg in "$@"; do ;; --version|-v) printf 'codiff v%s\n' "$(codiff_version)" + print_update_notice exit 0 ;; --walkthrough-guide) @@ -500,6 +540,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/update-notice.js b/bin/update-notice.js index f92a069..0f8213b 100644 --- a/bin/update-notice.js +++ b/bin/update-notice.js @@ -13,6 +13,6 @@ const { getAvailableUpdate, readUpdateState } = require('../electron/update-chec 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}). Open the app to update.` + ? `A new version of Codiff is available (v${currentVersion} -> v${update.version}). Run \`codiff update\` to update.` : null; } From 4c5f4b4f8b02e7ce7851f05ee9542a8f5b4f8f67 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 10:36:00 +0200 Subject: [PATCH 44/89] test(cli): expect the wrapper notice to survive corrupt cache input --- core/__tests__/codiff-app-notice.test.ts | 74 +++++++++++++++++++++++- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/core/__tests__/codiff-app-notice.test.ts b/core/__tests__/codiff-app-notice.test.ts index cd5437c..2eb51fa 100644 --- a/core/__tests__/codiff-app-notice.test.ts +++ b/core/__tests__/codiff-app-notice.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { mkdir, writeFile } from 'node:fs/promises'; +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'; @@ -18,9 +18,11 @@ const runVersion = async (home: string) => 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'), JSON.stringify(state, null, 2)); + await writeFile(join(home, '.codiff', 'update-state.json'), contents); }; test('prints no notice without cached update state', async () => { @@ -76,3 +78,69 @@ test('ignores malformed cached versions', async () => { 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('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(''); +}); From df3edcff4c4464ee05d12808e8866d1e00385b28 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 10:37:46 +0200 Subject: [PATCH 45/89] fix(cli): keep the wrapper update notice silent on corrupt cache input --- bin/codiff-app | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/bin/codiff-app b/bin/codiff-app index cccbb20..5de4e58 100755 --- a/bin/codiff-app +++ b/bin/codiff-app @@ -31,6 +31,10 @@ is_newer_version() { 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 @@ -45,16 +49,29 @@ is_newer_version() { } # 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. +# 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" ] || return 0 - latest="$(sed -n 's/.*"latestVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$state_file" | head -n 1)" - dismissed="$(sed -n 's/.*"dismissedVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$state_file" | head -n 1)" + [ -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)" + # The Node notice only trusts a state whose lastCheckedAt parses as a + # date; require the ISO timestamp shape the app writes. + case "$checked_at" in + [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T*) ;; + *) return 0 ;; + esac [ -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 } From 58e40ca1170e67b5becd8cb76b733576df9e471a Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 10:42:43 +0200 Subject: [PATCH 46/89] test(ui): describe the corner update pill with a details popover --- core/__tests__/UpdatePill.test.tsx | 278 +++++++++++++++++++++++++++++ core/app/components/Panels.tsx | 9 + 2 files changed, 287 insertions(+) create mode 100644 core/__tests__/UpdatePill.test.tsx diff --git a/core/__tests__/UpdatePill.test.tsx b/core/__tests__/UpdatePill.test.tsx new file mode 100644 index 0000000..164419d --- /dev/null +++ b/core/__tests__/UpdatePill.test.tsx @@ -0,0 +1,278 @@ +// @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'); + +const popover = (view: { container: HTMLElement }) => + view.container.querySelector('.update-popover'); + +const openPopover = async (view: { container: HTMLElement }) => { + await act(async () => pill(view)?.click()); + return popover(view); +}; + +test('renders nothing while no update is available', async () => { + await using view = await renderPill( + , + ); + + expect(pill(view)).toBeNull(); +}); + +test('shows a compact pill when an update is available', async () => { + await using view = await renderPill( + , + ); + + expect(pill(view)?.textContent).toContain('Update available'); + expect(pill(view)?.getAttribute('aria-expanded')).toBe('false'); + expect(popover(view)).toBeNull(); +}); + +test('opens the details popover with the version delta and actions', async () => { + await using view = await renderPill( + , + ); + + const details = await openPopover(view); + + expect(pill(view)?.getAttribute('aria-expanded')).toBe('true'); + expect(details?.textContent).toContain('v1.9.2'); + expect(details?.textContent).toContain('v1.9.3'); + expect(view.container.querySelector('.update-popover-primary')?.textContent).toContain( + 'Update now', + ); + expect(view.container.querySelector('.update-popover-later')?.textContent).toContain('Later'); + expect(view.container.querySelector('.update-popover-skip')?.textContent).toContain( + 'Skip this version', + ); + expect(view.container.querySelector('.update-popover-release-notes')?.textContent).toContain( + 'Release notes', + ); +}); + +test('applies the update from the popover and keeps it open', async () => { + let applied = 0; + await using view = await renderPill( + applied++} + onDismiss={noop} + onOpenReleasePage={noop} + status={status({ phase: 'available', version: '1.9.3' })} + />, + ); + + await openPopover(view); + const update = view.container.querySelector('.update-popover-primary'); + await act(async () => update?.click()); + + expect(applied).toBe(1); + expect(popover(view)).not.toBeNull(); +}); + +test('closes the popover through Later without dismissing the version', async () => { + let dismissed = 0; + await using view = await renderPill( + dismissed++} + onOpenReleasePage={noop} + status={status({ phase: 'available', version: '1.9.3' })} + />, + ); + + await openPopover(view); + const later = view.container.querySelector('.update-popover-later'); + await act(async () => later?.click()); + + expect(popover(view)).toBeNull(); + expect(pill(view)).not.toBeNull(); + expect(dismissed).toBe(0); +}); + +test('skips the offered version from the popover', async () => { + let dismissed = 0; + await using view = await renderPill( + dismissed++} + onOpenReleasePage={noop} + status={status({ phase: 'available', version: '1.9.3' })} + />, + ); + + await openPopover(view); + const skip = view.container.querySelector('.update-popover-skip'); + await act(async () => skip?.click()); + + expect(dismissed).toBe(1); +}); + +test('opens the release notes from the popover', async () => { + let opened = 0; + await using view = await renderPill( + opened++} + status={status({ phase: 'available', version: '1.9.3' })} + />, + ); + + await openPopover(view); + const notes = view.container.querySelector('.update-popover-release-notes'); + await act(async () => notes?.click()); + + expect(opened).toBe(1); +}); + +test('closes the popover with Escape', async () => { + await using view = await renderPill( + , + ); + + expect(await openPopover(view)).not.toBeNull(); + await act(async () => + document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' })), + ); + + expect(popover(view)).toBeNull(); +}); + +test('shows progress while updating without offering actions', async () => { + await using view = await renderPill( + , + ); + + expect(pill(view)?.textContent).toContain('Updating'); + + await openPopover(view); + + expect(popover(view)?.textContent).toContain('1.9.3'); + expect(view.container.querySelector('.update-popover-primary')).toBeNull(); + expect(view.container.querySelector('.update-popover-skip')).toBeNull(); +}); + +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'); + + await openPopover(view); + + expect(popover(view)?.textContent).not.toContain('undefined'); +}); + +test('tells the user to finish a handed-off install', async () => { + await using view = await renderPill( + , + ); + + expect(pill(view)?.textContent).toContain('Quit to finish update'); + + await openPopover(view); + + expect(popover(view)?.textContent).toContain('installer'); +}); + +test('offers retry and manual download after a failure', async () => { + let applied = 0; + let opened = 0; + await using view = await renderPill( + applied++} + onDismiss={noop} + onOpenReleasePage={() => opened++} + status={status({ message: 'feed unreachable', phase: 'error', version: '1.9.3' })} + />, + ); + + expect(pill(view)?.textContent).toContain('Update failed'); + + await openPopover(view); + + expect(popover(view)?.textContent).toContain('feed unreachable'); + + const retry = view.container.querySelector('.update-popover-primary'); + await act(async () => retry?.click()); + expect(applied).toBe(1); + + const manual = view.container.querySelector('.update-popover-manual'); + await act(async () => manual?.click()); + expect(opened).toBe(1); +}); diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index 492ac91..29463c9 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -85,6 +85,15 @@ export function RepositoryChangeBanner({ export type { CodiffUpdateStatus as UpdateStatus } from '../../types.ts'; +export function UpdatePill(_props: { + onApply: () => void; + onDismiss: () => void; + onOpenReleasePage: () => void; + status: import('../../types.ts').CodiffUpdateStatus; +}) { + return null; +} + export function UpdateBanner({ onApply, onDismiss, From 5e5d2c7fb8adfc87fe04590e4ab81eb94ebc3fb0 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 10:49:48 +0200 Subject: [PATCH 47/89] feat(ui): replace the update banner with a corner pill and details popover --- core/App.css | 222 ++++++++++++++++++++++----- core/App.tsx | 4 +- core/__tests__/UpdateBanner.test.tsx | 154 ------------------- core/app/components/Panels.tsx | 199 ++++++++++++++++++------ 4 files changed, 337 insertions(+), 242 deletions(-) delete mode 100644 core/__tests__/UpdateBanner.test.tsx diff --git a/core/App.css b/core/App.css index 2c581dc..7300caf 100644 --- a/core/App.css +++ b/core/App.css @@ -43,6 +43,8 @@ --text: #111111; --tree-selection-bg: #c2e8ff50; --tree-selection-focus: #3d87f5; + --update-accent: rgb(8 145 178); + --update-accent-contrast: #ffffff; --viewed: rgb(31 122 68); --font-mono: var(--codiff-font-mono); --font-sans: var(--codiff-font-sans); @@ -72,6 +74,8 @@ --text: rgb(230 230 230); --tree-selection-bg: #a5a5a540; --tree-selection-focus: #3d87f5; + --update-accent: rgb(34 211 238); + --update-accent-contrast: rgb(12 41 51); --viewed: rgb(111 208 148); color-scheme: dark; } @@ -101,6 +105,8 @@ --text: #111111; --tree-selection-bg: #c2e8ff50; --tree-selection-focus: #3d87f5; + --update-accent: rgb(8 145 178); + --update-accent-contrast: #ffffff; --viewed: rgb(31 122 68); color-scheme: light; } @@ -129,6 +135,8 @@ --text: rgb(230 230 230); --tree-selection-bg: #a5a5a540; --tree-selection-focus: #3d87f5; + --update-accent: rgb(34 211 238); + --update-accent-contrast: rgb(12 41 51); --viewed: rgb(111 208 148); color-scheme: dark; } @@ -1120,68 +1128,206 @@ a.review-top-bar-source:focus-visible { font-weight: 700; } -.update-banner { +.update-pill-anchor { -webkit-app-region: no-drag; + bottom: 16px; + left: 16px; + position: fixed; + z-index: 23; +} + +.update-pill { align-items: center; - background: color-mix(in srgb, var(--viewed) 10%, var(--code-bg)); - border: 1px solid color-mix(in srgb, var(--viewed) 18%, transparent); - border-radius: 17px; - box-shadow: 0 16px 46px -30px color-mix(in srgb, var(--viewed) 52%, rgb(0 0 0)); - color: color-mix(in srgb, var(--viewed) 36%, var(--text)); + animation: update-pill-in 360ms cubic-bezier(0.2, 0.82, 0.22, 1) both; + backdrop-filter: blur(22px) saturate(1.35); + background: color-mix(in srgb, var(--update-accent) 14%, var(--code-bg)); + border: 1px solid color-mix(in srgb, var(--update-accent) 42%, transparent); + border-radius: 15px; + box-shadow: 0 12px 34px -18px color-mix(in srgb, var(--update-accent) 70%, rgb(0 0 0)); + color: color-mix(in srgb, var(--update-accent) 68%, var(--text)); corner-shape: squircle; + cursor: pointer; display: inline-flex; + font: inherit; + font-size: 12.5px; + font-weight: 700; + gap: 6px; + line-height: 1; + padding: 8px 12px; + transition: + background 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; +} + +.update-pill:hover { + background: color-mix(in srgb, var(--update-accent) 22%, var(--code-bg)); + transform: translateY(-1px); +} + +.update-pill.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.error { + background: color-mix(in srgb, var(--danger) 14%, var(--code-bg)); + border-color: color-mix(in srgb, var(--danger) 42%, transparent); + box-shadow: 0 12px 34px -18px color-mix(in srgb, var(--danger) 70%, rgb(0 0 0)); + color: color-mix(in srgb, var(--danger) 62%, var(--text)); +} + +.update-pill-spinner { + animation: update-pill-spin 900ms linear infinite; +} + +@keyframes update-pill-in { + from { + opacity: 0; + transform: translateY(14px) scale(0.96); + } +} + +@keyframes update-pill-glow { + 50% { + box-shadow: + 0 12px 34px -18px color-mix(in srgb, var(--update-accent) 70%, rgb(0 0 0)), + 0 0 0 6px color-mix(in srgb, var(--update-accent) 18%, transparent); + } +} + +@keyframes update-pill-spin { + to { + transform: rotate(360deg); + } +} + +.update-popover { + animation: update-popover-in 200ms cubic-bezier(0.2, 0.82, 0.22, 1) both; + backdrop-filter: blur(24px) saturate(1.35); + background: color-mix(in srgb, var(--code-bg) 82%, transparent); + border: 1px solid var(--file-border); + border-radius: 14px; + bottom: calc(100% + 10px); + box-shadow: 0 18px 58px -30px rgb(0 0 0 / 0.7); + color: var(--text); + corner-shape: squircle; + display: flex; + flex-direction: column; font-size: 13px; - font-weight: 600; + gap: 10px; + left: 0; + line-height: 1.4; + padding: 12px 14px; + position: absolute; + width: min(320px, calc(100vw - 32px)); +} + +@keyframes update-popover-in { + from { + opacity: 0; + transform: translateY(6px) scale(0.98); + } +} + +.update-popover-header { + align-items: center; + display: flex; + font-size: 13.5px; + gap: 7px; +} + +.update-popover-sparkle { + color: var(--update-accent); +} + +.update-popover-danger { + color: var(--danger); +} + +.update-popover-meta { + align-items: center; + color: var(--muted); + display: flex; + font-size: 12px; gap: 8px; - left: 50%; - line-height: 1.25; - max-width: min(620px, calc(100vw - 48px)); - padding: 8px 8px 8px 13px; - pointer-events: none; - position: fixed; - top: 18px; - transform: translate(-50%, -66px); - transition: - opacity 180ms ease, - transform 320ms cubic-bezier(0.2, 0.82, 0.22, 1); - z-index: 23; } -.update-banner.visible { - opacity: 1; - pointer-events: auto; - transform: translate(-50%, 0); +.update-popover-body { + color: var(--muted); + margin: 0; } -.update-banner:not(.visible) { - opacity: 0; +.update-popover-release-notes { + background: none; + border: 0; + color: var(--update-accent); + cursor: pointer; + font: inherit; + padding: 0; + text-decoration: underline; + text-underline-offset: 2px; +} + +.update-popover-release-notes:hover { + text-decoration: none; } -.update-banner-content { +.update-popover-actions { align-items: center; - display: inline-flex; + display: flex; flex-wrap: wrap; - gap: 5px; + gap: 6px; +} + +.update-popover-primary { + background: var(--update-accent); + border: 0; + border-radius: 9px; + color: var(--update-accent-contrast); + cursor: pointer; + font: inherit; + font-size: 12.5px; + font-weight: 700; + padding: 6px 12px; } -.update-banner button { +.update-popover-primary:hover { + filter: brightness(1.07); +} + +.update-popover-later, +.update-popover-manual, +.update-popover-skip { background: transparent; border: 0; - color: inherit; + border-radius: 9px; + color: var(--muted); cursor: pointer; font: inherit; - padding: 0; + font-size: 12.5px; + font-weight: 600; + padding: 6px 10px; } -.update-banner-action, -.update-banner-manual { - text-decoration: underline; - text-underline-offset: 2px; +.update-popover-later:hover, +.update-popover-manual:hover, +.update-popover-skip:hover { + background: var(--hover-wash); + color: var(--text); } -.update-banner-action:hover, -.update-banner-manual:hover { - text-decoration: none; +.update-popover-skip { + margin-left: auto; +} + +@media (prefers-reduced-motion: reduce) { + .update-pill, + .update-pill-spinner, + .update-popover { + animation: none; + } } .diff-search-panel { diff --git a/core/App.tsx b/core/App.tsx index 60de1dc..55c1c40 100644 --- a/core/App.tsx +++ b/core/App.tsx @@ -18,7 +18,7 @@ import { RepositoryChangeBanner, RepositoryLoadErrorPanel, ReviewSourceLoading, - UpdateBanner, + UpdatePill, WalkthroughOutdatedBanner, } from './app/components/Panels.tsx'; import { PlanEditorView } from './app/components/PlanEditorView.tsx'; @@ -1862,7 +1862,7 @@ export default function App() { reason={walkthroughFileError?.reason ?? null} /> {updateStatus ? ( - { window.codiff.applyUpdate().then(setUpdateStatus, () => {}); }} diff --git a/core/__tests__/UpdateBanner.test.tsx b/core/__tests__/UpdateBanner.test.tsx deleted file mode 100644 index 4e7cc51..0000000 --- a/core/__tests__/UpdateBanner.test.tsx +++ /dev/null @@ -1,154 +0,0 @@ -// @vitest-environment jsdom - -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { expect, test } from 'vite-plus/test'; -import { UpdateBanner, 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 renderBanner = 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 banner = (view: { container: HTMLElement }) => view.container.querySelector('.update-banner'); - -test('stays hidden while no update is available', async () => { - await using view = await renderBanner( - , - ); - - expect(banner(view)?.classList.contains('visible')).toBe(false); -}); - -test('shows the available version with an update action', async () => { - let applied = 0; - await using view = await renderBanner( - applied++} - onDismiss={noop} - onOpenReleasePage={noop} - status={status({ phase: 'available', version: '1.9.3' })} - />, - ); - - expect(banner(view)?.classList.contains('visible')).toBe(true); - expect(banner(view)?.textContent).toContain('1.9.3'); - - const update = view.container.querySelector('.update-banner-action'); - await act(async () => update?.click()); - expect(applied).toBe(1); -}); - -test('dismisses through the close button', async () => { - let dismissed = 0; - await using view = await renderBanner( - dismissed++} - onOpenReleasePage={noop} - status={status({ phase: 'available', version: '1.9.3' })} - />, - ); - - const dismiss = view.container.querySelector('.repository-change-dismiss'); - await act(async () => dismiss?.click()); - expect(dismissed).toBe(1); -}); - -test('shows progress while updating without actions', async () => { - await using view = await renderBanner( - , - ); - - expect(banner(view)?.classList.contains('visible')).toBe(true); - expect(banner(view)?.textContent).toContain('Updating'); - expect(view.container.querySelector('.update-banner-action')).toBeNull(); - expect(view.container.querySelector('.repository-change-dismiss')).toBeNull(); -}); - -test('shows progress without a version after an error retry', async () => { - await using view = await renderBanner( - , - ); - - expect(banner(view)?.textContent).toContain('Updating Codiff'); - expect(banner(view)?.textContent).not.toContain('undefined'); -}); - -test('tells the user to finish a handed-off install', async () => { - await using view = await renderBanner( - , - ); - - expect(banner(view)?.textContent).toContain('installer'); -}); - -test('offers retry and manual download after a failure', async () => { - let applied = 0; - let opened = 0; - await using view = await renderBanner( - applied++} - onDismiss={noop} - onOpenReleasePage={() => opened++} - status={status({ message: 'feed unreachable', phase: 'error', version: '1.9.3' })} - />, - ); - - expect(banner(view)?.textContent).toContain('failed'); - - const retry = view.container.querySelector('.update-banner-action'); - await act(async () => retry?.click()); - expect(applied).toBe(1); - - const manual = view.container.querySelector('.update-banner-manual'); - await act(async () => manual?.click()); - expect(opened).toBe(1); -}); diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index 29463c9..4323586 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -4,8 +4,10 @@ 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 { SparkleIcon as Sparkle } from '@phosphor-icons/react/Sparkle'; import { WarningOctagonIcon as WarningOctagon } from '@phosphor-icons/react/WarningOctagon'; import { XIcon as X } from '@phosphor-icons/react/X'; import { Copy as LucideCopy } from 'lucide-react'; @@ -24,6 +26,7 @@ import type { RepositoryLoadError, ReviewComment } from '../../lib/app-types.ts' import { buildReviewCommentsMarkdown } from '../../lib/review-comments.ts'; import type { ChangedFile, + CodiffUpdateStatus, PullRequestMergeOptions, PullRequestMergeState, PullRequestReviewEvent, @@ -85,16 +88,7 @@ export function RepositoryChangeBanner({ export type { CodiffUpdateStatus as UpdateStatus } from '../../types.ts'; -export function UpdatePill(_props: { - onApply: () => void; - onDismiss: () => void; - onOpenReleasePage: () => void; - status: import('../../types.ts').CodiffUpdateStatus; -}) { - return null; -} - -export function UpdateBanner({ +export function UpdatePill({ onApply, onDismiss, onOpenReleasePage, @@ -103,48 +97,157 @@ export function UpdateBanner({ onApply: () => void; onDismiss: () => void; onOpenReleasePage: () => void; - status: import('../../types.ts').CodiffUpdateStatus; + status: CodiffUpdateStatus; }) { - const { message, phase, version } = status; - const isVisible = phase !== 'idle'; + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + const { currentVersion, message, phase, version } = status; + + useEffect(() => { + if (!open) { + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + } + }; + const onPointerDown = (event: PointerEvent) => { + // oxlint-disable-next-line @nkzw/no-instanceof + if (event.target instanceof Node && !anchorRef.current?.contains(event.target)) { + setOpen(false); + } + }; + document.addEventListener('keydown', onKeyDown); + document.addEventListener('pointerdown', onPointerDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('pointerdown', onPointerDown); + }; + }, [open]); + + if (phase === 'idle') { + // Close the leftover popover so it does not flash open on the next update. + if (open) { + setOpen(false); + } + return null; + } return ( -
- - {phase === 'available' ? ( - <> - {`Codiff ${version} is available,`} - - - ) : phase === 'updating' ? ( - {version ? `Updating to Codiff ${version}…` : 'Updating Codiff…'} - ) : phase === 'installerReady' ? ( - The installer was downloaded and opened. Quit Codiff to finish updating. +
+ - or - - - ) : null} - - {phase === 'available' || phase === 'error' ? ( - + + ) : ( + + )} + + {phase === 'available' + ? 'Update available' + : phase === 'updating' + ? 'Updating…' + : phase === 'installerReady' + ? 'Quit to finish update' + : 'Update failed'} + + + {open ? ( +
+ {phase === 'available' ? ( + <> +
+ + Update available! +
+
+ {version ? {`v${currentVersion} -> v${version}`} : null} + +
+
+ + + +
+ + ) : phase === 'updating' ? ( + <> +
+ + {version ? `Updating to Codiff ${version}…` : 'Updating Codiff…'} +
+

+ Downloading the update, this only takes a moment. +

+ + ) : phase === 'installerReady' ? ( + <> +
+ + Almost there +
+

+ The installer was downloaded and opened. Quit Codiff to finish updating. +

+ + ) : ( + <> +
+ + Update failed +
+

+ {message ?? 'Something went wrong while updating.'} +

+
+ + + +
+ + )} +
) : null}
); From f1424b760b8d40c903a762249ddde3006873ed90 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 11:00:48 +0200 Subject: [PATCH 48/89] style(ui): dock the update pill into the sidebar footer --- core/App.css | 46 +++++++++++++++++++++------------------------- core/App.tsx | 28 ++++++++++++++-------------- 2 files changed, 35 insertions(+), 39 deletions(-) diff --git a/core/App.css b/core/App.css index 7300caf..1f6e8d1 100644 --- a/core/App.css +++ b/core/App.css @@ -1130,39 +1130,37 @@ a.review-top-bar-source:focus-visible { .update-pill-anchor { -webkit-app-region: no-drag; - bottom: 16px; - left: 16px; - position: fixed; - z-index: 23; + border-top: 1px solid var(--sidebar-border); + flex: none; + padding: 8px; + position: relative; } .update-pill { align-items: center; animation: update-pill-in 360ms cubic-bezier(0.2, 0.82, 0.22, 1) both; - backdrop-filter: blur(22px) saturate(1.35); - background: color-mix(in srgb, var(--update-accent) 14%, var(--code-bg)); - border: 1px solid color-mix(in srgb, var(--update-accent) 42%, transparent); - border-radius: 15px; - box-shadow: 0 12px 34px -18px color-mix(in srgb, var(--update-accent) 70%, rgb(0 0 0)); + background: color-mix(in srgb, var(--update-accent) 13%, transparent); + border: 1px solid color-mix(in srgb, var(--update-accent) 36%, transparent); + border-radius: 10px; color: color-mix(in srgb, var(--update-accent) 68%, var(--text)); corner-shape: squircle; cursor: pointer; - display: inline-flex; + display: flex; font: inherit; font-size: 12.5px; font-weight: 700; gap: 6px; line-height: 1; - padding: 8px 12px; + padding: 7px 10px; transition: background 160ms ease, - box-shadow 160ms ease, - transform 160ms ease; + border-color 160ms ease; + width: 100%; } .update-pill:hover { - background: color-mix(in srgb, var(--update-accent) 22%, var(--code-bg)); - transform: translateY(-1px); + background: color-mix(in srgb, var(--update-accent) 21%, transparent); + border-color: color-mix(in srgb, var(--update-accent) 52%, transparent); } .update-pill.available { @@ -1172,9 +1170,8 @@ a.review-top-bar-source:focus-visible { } .update-pill.error { - background: color-mix(in srgb, var(--danger) 14%, var(--code-bg)); - border-color: color-mix(in srgb, var(--danger) 42%, transparent); - box-shadow: 0 12px 34px -18px color-mix(in srgb, var(--danger) 70%, rgb(0 0 0)); + background: color-mix(in srgb, var(--danger) 13%, transparent); + border-color: color-mix(in srgb, var(--danger) 36%, transparent); color: color-mix(in srgb, var(--danger) 62%, var(--text)); } @@ -1185,15 +1182,13 @@ a.review-top-bar-source:focus-visible { @keyframes update-pill-in { from { opacity: 0; - transform: translateY(14px) scale(0.96); + transform: translateY(8px); } } @keyframes update-pill-glow { 50% { - box-shadow: - 0 12px 34px -18px color-mix(in srgb, var(--update-accent) 70%, rgb(0 0 0)), - 0 0 0 6px color-mix(in srgb, var(--update-accent) 18%, transparent); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--update-accent) 16%, transparent); } } @@ -1209,7 +1204,7 @@ a.review-top-bar-source:focus-visible { background: color-mix(in srgb, var(--code-bg) 82%, transparent); border: 1px solid var(--file-border); border-radius: 14px; - bottom: calc(100% + 10px); + bottom: calc(100% + 8px); box-shadow: 0 18px 58px -30px rgb(0 0 0 / 0.7); color: var(--text); corner-shape: squircle; @@ -1217,11 +1212,12 @@ a.review-top-bar-source:focus-visible { flex-direction: column; font-size: 13px; gap: 10px; - left: 0; + left: 8px; line-height: 1.4; padding: 12px 14px; position: absolute; - width: min(320px, calc(100vw - 32px)); + width: min(320px, calc(100cqw - 16px)); + z-index: 6; } @keyframes update-popover-in { diff --git a/core/App.tsx b/core/App.tsx index 55c1c40..846e703 100644 --- a/core/App.tsx +++ b/core/App.tsx @@ -1861,20 +1861,6 @@ export default function App() { onDismiss={() => setWalkthroughFileError(null)} reason={walkthroughFileError?.reason ?? null} /> - {updateStatus ? ( - { - window.codiff.applyUpdate().then(setUpdateStatus, () => {}); - }} - onDismiss={() => { - window.codiff.dismissUpdate().then(setUpdateStatus, () => {}); - }} - onOpenReleasePage={() => { - window.codiff.openReleasePage().catch(() => {}); - }} - status={updateStatus} - /> - ) : null} + {updateStatus ? ( + { + window.codiff.applyUpdate().then(setUpdateStatus, () => {}); + }} + onDismiss={() => { + window.codiff.dismissUpdate().then(setUpdateStatus, () => {}); + }} + onOpenReleasePage={() => { + window.codiff.openReleasePage().catch(() => {}); + }} + status={updateStatus} + /> + ) : null}
From 90db1b6e2b12daadc8ce76ffd51b296374fa3cd3 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 11:06:14 +0200 Subject: [PATCH 49/89] test(cli): expect the wrapper notice to reject unparseable timestamps --- core/__tests__/codiff-app-notice.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/core/__tests__/codiff-app-notice.test.ts b/core/__tests__/codiff-app-notice.test.ts index 2eb51fa..c4c8887 100644 --- a/core/__tests__/codiff-app-notice.test.ts +++ b/core/__tests__/codiff-app-notice.test.ts @@ -135,6 +135,20 @@ test('stays silent for invalid JSON with an unparseable lastCheckedAt', async () 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( From 4119cc472d9822547bb00fb2316db050ffe887a2 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 11:08:26 +0200 Subject: [PATCH 50/89] fix(cli): validate the cached timestamp like Date.parse before noticing --- bin/codiff-app | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/bin/codiff-app b/bin/codiff-app index 5de4e58..858c12b 100755 --- a/bin/codiff-app +++ b/bin/codiff-app @@ -48,6 +48,28 @@ is_newer_version() { [ "$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 and day-in-month overflow the ranges cannot catch, stays silent. +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 @@ -60,12 +82,7 @@ print_update_notice() { 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)" - # The Node notice only trusts a state whose lastCheckedAt parses as a - # date; require the ISO timestamp shape the app writes. - case "$checked_at" in - [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T*) ;; - *) return 0 ;; - esac + is_iso_timestamp "$checked_at" || return 0 [ -n "$latest" ] || return 0 [ "$latest" = "$dismissed" ] && return 0 current="$(codiff_version)" From c3804abd7e21ce4ae0e49e8e1bdb34effca1e740 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 11:08:28 +0200 Subject: [PATCH 51/89] style(ui): raise the light update accent to meet text contrast --- core/App.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/App.css b/core/App.css index 1f6e8d1..098eac2 100644 --- a/core/App.css +++ b/core/App.css @@ -43,7 +43,7 @@ --text: #111111; --tree-selection-bg: #c2e8ff50; --tree-selection-focus: #3d87f5; - --update-accent: rgb(8 145 178); + --update-accent: rgb(14 116 144); --update-accent-contrast: #ffffff; --viewed: rgb(31 122 68); --font-mono: var(--codiff-font-mono); @@ -105,7 +105,7 @@ --text: #111111; --tree-selection-bg: #c2e8ff50; --tree-selection-focus: #3d87f5; - --update-accent: rgb(8 145 178); + --update-accent: rgb(14 116 144); --update-accent-contrast: #ffffff; --viewed: rgb(31 122 68); color-scheme: light; From c90b2bce900dde0d12de350cd60574bba670231b Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 11:11:00 +0200 Subject: [PATCH 52/89] test(ui): expect a corner close button on the update popover --- core/__tests__/UpdatePill.test.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/core/__tests__/UpdatePill.test.tsx b/core/__tests__/UpdatePill.test.tsx index 164419d..8f9f635 100644 --- a/core/__tests__/UpdatePill.test.tsx +++ b/core/__tests__/UpdatePill.test.tsx @@ -178,6 +178,26 @@ test('opens the release notes from the popover', async () => { expect(opened).toBe(1); }); +test('closes the popover through the corner close button without dismissing', async () => { + let dismissed = 0; + await using view = await renderPill( + dismissed++} + onOpenReleasePage={noop} + status={status({ phase: 'available', version: '1.9.3' })} + />, + ); + + expect(await openPopover(view)).not.toBeNull(); + const close = view.container.querySelector('.update-popover-close'); + await act(async () => close?.click()); + + expect(popover(view)).toBeNull(); + expect(pill(view)).not.toBeNull(); + expect(dismissed).toBe(0); +}); + test('closes the popover with Escape', async () => { await using view = await renderPill( Date: Wed, 29 Jul 2026 11:11:42 +0200 Subject: [PATCH 53/89] feat(ui): add a corner close button to the update popover --- core/App.css | 23 +++++++++++++++++++++++ core/app/components/Panels.tsx | 8 ++++++++ 2 files changed, 31 insertions(+) diff --git a/core/App.css b/core/App.css index 098eac2..b36a6f8 100644 --- a/core/App.css +++ b/core/App.css @@ -1232,6 +1232,29 @@ a.review-top-bar-source:focus-visible { display: flex; font-size: 13.5px; gap: 7px; + padding-right: 22px; +} + +.update-popover-close { + align-items: center; + background: transparent; + border: 0; + border-radius: 7px; + color: var(--muted); + cursor: pointer; + display: inline-flex; + height: 22px; + justify-content: center; + padding: 0; + position: absolute; + right: 8px; + top: 8px; + width: 22px; +} + +.update-popover-close:hover { + background: var(--hover-wash); + color: var(--text); } .update-popover-sparkle { diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index 4323586..53c6078 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -163,6 +163,14 @@ export function UpdatePill({ {open ? (
+ {phase === 'available' ? ( <>
From 403c25db51f16f622fd0dc041a0ac4489ffe00df Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 11:25:20 +0200 Subject: [PATCH 54/89] docs(cli): correct the day-overflow note on is_iso_timestamp --- bin/codiff-app | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/codiff-app b/bin/codiff-app index 858c12b..1c7b75d 100755 --- a/bin/codiff-app +++ b/bin/codiff-app @@ -51,7 +51,8 @@ is_newer_version() { # 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 and day-in-month overflow the ranges cannot catch, stays silent. +# 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]*) ;; From 83cd99a3f8e0e455d3ffbed29be5e4f7e324c58f Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 18:04:39 +0200 Subject: [PATCH 55/89] test(ui): describe the one-click update pill --- core/__tests__/UpdatePill.test.tsx | 216 ++++------------------------- core/app/components/Panels.tsx | 4 +- 2 files changed, 30 insertions(+), 190 deletions(-) diff --git a/core/__tests__/UpdatePill.test.tsx b/core/__tests__/UpdatePill.test.tsx index 8f9f635..12f4570 100644 --- a/core/__tests__/UpdatePill.test.tsx +++ b/core/__tests__/UpdatePill.test.tsx @@ -40,259 +40,99 @@ const status = (partial: Partial): UpdateStatus => ({ const pill = (view: { container: HTMLElement }) => view.container.querySelector('.update-pill'); -const popover = (view: { container: HTMLElement }) => - view.container.querySelector('.update-popover'); - -const openPopover = async (view: { container: HTMLElement }) => { - await act(async () => pill(view)?.click()); - return popover(view); -}; - test('renders nothing while no update is available', async () => { await using view = await renderPill( - , + , ); expect(pill(view)).toBeNull(); }); -test('shows a compact pill when an update is available', async () => { +test('shows a single Update button when an update is available', async () => { await using view = await renderPill( - , + , ); - expect(pill(view)?.textContent).toContain('Update available'); - expect(pill(view)?.getAttribute('aria-expanded')).toBe('false'); - expect(popover(view)).toBeNull(); -}); - -test('opens the details popover with the version delta and actions', async () => { - await using view = await renderPill( - , - ); - - const details = await openPopover(view); - - expect(pill(view)?.getAttribute('aria-expanded')).toBe('true'); - expect(details?.textContent).toContain('v1.9.2'); - expect(details?.textContent).toContain('v1.9.3'); - expect(view.container.querySelector('.update-popover-primary')?.textContent).toContain( - 'Update now', - ); - expect(view.container.querySelector('.update-popover-later')?.textContent).toContain('Later'); - expect(view.container.querySelector('.update-popover-skip')?.textContent).toContain( - 'Skip this version', - ); - expect(view.container.querySelector('.update-popover-release-notes')?.textContent).toContain( - 'Release notes', - ); + 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('applies the update from the popover and keeps it open', async () => { +test('applies the update with a single click', async () => { let applied = 0; await using view = await renderPill( applied++} - onDismiss={noop} - onOpenReleasePage={noop} status={status({ phase: 'available', version: '1.9.3' })} />, ); - await openPopover(view); - const update = view.container.querySelector('.update-popover-primary'); - await act(async () => update?.click()); + await act(async () => pill(view)?.click()); expect(applied).toBe(1); - expect(popover(view)).not.toBeNull(); -}); - -test('closes the popover through Later without dismissing the version', async () => { - let dismissed = 0; - await using view = await renderPill( - dismissed++} - onOpenReleasePage={noop} - status={status({ phase: 'available', version: '1.9.3' })} - />, - ); - - await openPopover(view); - const later = view.container.querySelector('.update-popover-later'); - await act(async () => later?.click()); - - expect(popover(view)).toBeNull(); - expect(pill(view)).not.toBeNull(); - expect(dismissed).toBe(0); + expect(view.container.querySelector('.update-popover')).toBeNull(); }); -test('skips the offered version from the popover', async () => { - let dismissed = 0; - await using view = await renderPill( - dismissed++} - onOpenReleasePage={noop} - status={status({ phase: 'available', version: '1.9.3' })} - />, - ); - - await openPopover(view); - const skip = view.container.querySelector('.update-popover-skip'); - await act(async () => skip?.click()); - - expect(dismissed).toBe(1); -}); - -test('opens the release notes from the popover', async () => { - let opened = 0; - await using view = await renderPill( - opened++} - status={status({ phase: 'available', version: '1.9.3' })} - />, - ); - - await openPopover(view); - const notes = view.container.querySelector('.update-popover-release-notes'); - await act(async () => notes?.click()); - - expect(opened).toBe(1); -}); - -test('closes the popover through the corner close button without dismissing', async () => { - let dismissed = 0; - await using view = await renderPill( - dismissed++} - onOpenReleasePage={noop} - status={status({ phase: 'available', version: '1.9.3' })} - />, - ); - - expect(await openPopover(view)).not.toBeNull(); - const close = view.container.querySelector('.update-popover-close'); - await act(async () => close?.click()); - - expect(popover(view)).toBeNull(); - expect(pill(view)).not.toBeNull(); - expect(dismissed).toBe(0); -}); - -test('closes the popover with Escape', async () => { - await using view = await renderPill( - , - ); - - expect(await openPopover(view)).not.toBeNull(); - await act(async () => - document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' })), - ); - - expect(popover(view)).toBeNull(); -}); - -test('shows progress while updating without offering actions', async () => { +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 openPopover(view); + await act(async () => pill(view)?.click()); - expect(popover(view)?.textContent).toContain('1.9.3'); - expect(view.container.querySelector('.update-popover-primary')).toBeNull(); - expect(view.container.querySelector('.update-popover-skip')).toBeNull(); + 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'); - - await openPopover(view); - - expect(popover(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 openPopover(view); + await act(async () => pill(view)?.click()); - expect(popover(view)?.textContent).toContain('installer'); + expect(applied).toBe(0); }); -test('offers retry and manual download after a failure', async () => { +test('retries a failed update with a single click', async () => { let applied = 0; - let opened = 0; await using view = await renderPill( applied++} - onDismiss={noop} - onOpenReleasePage={() => opened++} 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 openPopover(view); - - expect(popover(view)?.textContent).toContain('feed unreachable'); + await act(async () => pill(view)?.click()); - const retry = view.container.querySelector('.update-popover-primary'); - await act(async () => retry?.click()); expect(applied).toBe(1); - - const manual = view.container.querySelector('.update-popover-manual'); - await act(async () => manual?.click()); - expect(opened).toBe(1); }); diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index 53c6078..90593c2 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -95,8 +95,8 @@ export function UpdatePill({ status, }: { onApply: () => void; - onDismiss: () => void; - onOpenReleasePage: () => void; + onDismiss?: () => void; + onOpenReleasePage?: () => void; status: CodiffUpdateStatus; }) { const [open, setOpen] = useState(false); From 0dc21f071611bc7c24be3a754a5199e3f6c9e5db Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 18:08:14 +0200 Subject: [PATCH 56/89] feat(ui): apply the update directly from the pill --- core/App.css | 157 +++------------------------------ core/App.tsx | 6 -- core/app/components/Panels.tsx | 154 ++++---------------------------- 3 files changed, 29 insertions(+), 288 deletions(-) diff --git a/core/App.css b/core/App.css index b36a6f8..f4c2308 100644 --- a/core/App.css +++ b/core/App.css @@ -1158,11 +1158,15 @@ a.review-top-bar-source:focus-visible { width: 100%; } -.update-pill:hover { +.update-pill:hover:not(:disabled) { background: color-mix(in srgb, var(--update-accent) 21%, transparent); border-color: color-mix(in srgb, var(--update-accent) 52%, transparent); } +.update-pill:disabled { + cursor: default; +} + .update-pill.available { animation: update-pill-in 360ms cubic-bezier(0.2, 0.82, 0.22, 1) both, @@ -1175,6 +1179,11 @@ a.review-top-bar-source:focus-visible { color: color-mix(in srgb, var(--danger) 62%, var(--text)); } +.update-pill.error:hover:not(:disabled) { + background: color-mix(in srgb, var(--danger) 21%, transparent); + border-color: color-mix(in srgb, var(--danger) 52%, transparent); +} + .update-pill-spinner { animation: update-pill-spin 900ms linear infinite; } @@ -1198,153 +1207,9 @@ a.review-top-bar-source:focus-visible { } } -.update-popover { - animation: update-popover-in 200ms cubic-bezier(0.2, 0.82, 0.22, 1) both; - backdrop-filter: blur(24px) saturate(1.35); - background: color-mix(in srgb, var(--code-bg) 82%, transparent); - border: 1px solid var(--file-border); - border-radius: 14px; - bottom: calc(100% + 8px); - box-shadow: 0 18px 58px -30px rgb(0 0 0 / 0.7); - color: var(--text); - corner-shape: squircle; - display: flex; - flex-direction: column; - font-size: 13px; - gap: 10px; - left: 8px; - line-height: 1.4; - padding: 12px 14px; - position: absolute; - width: min(320px, calc(100cqw - 16px)); - z-index: 6; -} - -@keyframes update-popover-in { - from { - opacity: 0; - transform: translateY(6px) scale(0.98); - } -} - -.update-popover-header { - align-items: center; - display: flex; - font-size: 13.5px; - gap: 7px; - padding-right: 22px; -} - -.update-popover-close { - align-items: center; - background: transparent; - border: 0; - border-radius: 7px; - color: var(--muted); - cursor: pointer; - display: inline-flex; - height: 22px; - justify-content: center; - padding: 0; - position: absolute; - right: 8px; - top: 8px; - width: 22px; -} - -.update-popover-close:hover { - background: var(--hover-wash); - color: var(--text); -} - -.update-popover-sparkle { - color: var(--update-accent); -} - -.update-popover-danger { - color: var(--danger); -} - -.update-popover-meta { - align-items: center; - color: var(--muted); - display: flex; - font-size: 12px; - gap: 8px; -} - -.update-popover-body { - color: var(--muted); - margin: 0; -} - -.update-popover-release-notes { - background: none; - border: 0; - color: var(--update-accent); - cursor: pointer; - font: inherit; - padding: 0; - text-decoration: underline; - text-underline-offset: 2px; -} - -.update-popover-release-notes:hover { - text-decoration: none; -} - -.update-popover-actions { - align-items: center; - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.update-popover-primary { - background: var(--update-accent); - border: 0; - border-radius: 9px; - color: var(--update-accent-contrast); - cursor: pointer; - font: inherit; - font-size: 12.5px; - font-weight: 700; - padding: 6px 12px; -} - -.update-popover-primary:hover { - filter: brightness(1.07); -} - -.update-popover-later, -.update-popover-manual, -.update-popover-skip { - background: transparent; - border: 0; - border-radius: 9px; - color: var(--muted); - cursor: pointer; - font: inherit; - font-size: 12.5px; - font-weight: 600; - padding: 6px 10px; -} - -.update-popover-later:hover, -.update-popover-manual:hover, -.update-popover-skip:hover { - background: var(--hover-wash); - color: var(--text); -} - -.update-popover-skip { - margin-left: auto; -} - @media (prefers-reduced-motion: reduce) { .update-pill, - .update-pill-spinner, - .update-popover { + .update-pill-spinner { animation: none; } } diff --git a/core/App.tsx b/core/App.tsx index 846e703..ea72ec7 100644 --- a/core/App.tsx +++ b/core/App.tsx @@ -1948,12 +1948,6 @@ export default function App() { onApply={() => { window.codiff.applyUpdate().then(setUpdateStatus, () => {}); }} - onDismiss={() => { - window.codiff.dismissUpdate().then(setUpdateStatus, () => {}); - }} - onOpenReleasePage={() => { - window.codiff.openReleasePage().catch(() => {}); - }} status={updateStatus} /> ) : null} diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index 90593c2..bf4fbcf 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -90,58 +90,36 @@ export type { CodiffUpdateStatus as UpdateStatus } from '../../types.ts'; export function UpdatePill({ onApply, - onDismiss, - onOpenReleasePage, status, }: { onApply: () => void; - onDismiss?: () => void; - onOpenReleasePage?: () => void; status: CodiffUpdateStatus; }) { - const [open, setOpen] = useState(false); - const anchorRef = useRef(null); const { currentVersion, message, phase, version } = status; - useEffect(() => { - if (!open) { - return; - } - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setOpen(false); - } - }; - const onPointerDown = (event: PointerEvent) => { - // oxlint-disable-next-line @nkzw/no-instanceof - if (event.target instanceof Node && !anchorRef.current?.contains(event.target)) { - setOpen(false); - } - }; - document.addEventListener('keydown', onKeyDown); - document.addEventListener('pointerdown', onPointerDown); - return () => { - document.removeEventListener('keydown', onKeyDown); - document.removeEventListener('pointerdown', onPointerDown); - }; - }, [open]); - if (phase === 'idle') { - // Close the leftover popover so it does not flash open on the next update. - if (open) { - setOpen(false); - } return null; } + const actionable = phase === 'available' || phase === 'error'; + const title = + phase === 'available' + ? `Update Codiff${version ? ` v${currentVersion} -> v${version}` : ''}. Downloads the update and restarts the app.` + : 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 ( -
+
- {open ? ( -
- - {phase === 'available' ? ( - <> -
- - Update available! -
-
- {version ? {`v${currentVersion} -> v${version}`} : null} - -
-
- - - -
- - ) : phase === 'updating' ? ( - <> -
- - {version ? `Updating to Codiff ${version}…` : 'Updating Codiff…'} -
-

- Downloading the update, this only takes a moment. -

- - ) : phase === 'installerReady' ? ( - <> -
- - Almost there -
-

- The installer was downloaded and opened. Quit Codiff to finish updating. -

- - ) : ( - <> -
- - Update failed -
-

- {message ?? 'Something went wrong while updating.'} -

-
- - - -
- - )} -
- ) : null}
); } From 541d3e70cacc200d97fdda65f25035d881244430 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 18:18:49 +0200 Subject: [PATCH 57/89] test(ui): expect platform-neutral install copy on the update pill --- core/__tests__/UpdatePill.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/core/__tests__/UpdatePill.test.tsx b/core/__tests__/UpdatePill.test.tsx index 12f4570..a2a6cb6 100644 --- a/core/__tests__/UpdatePill.test.tsx +++ b/core/__tests__/UpdatePill.test.tsx @@ -59,6 +59,15 @@ test('shows a single Update button when an update is available', async () => { expect(view.container.querySelector('.update-popover')).toBeNull(); }); +test('does not promise a restart the download strategy cannot deliver', async () => { + await using view = await renderPill( + , + ); + + expect(pill(view)?.title).toContain('Downloads and installs'); + expect(pill(view)?.title).not.toContain('restarts'); +}); + test('applies the update with a single click', async () => { let applied = 0; await using view = await renderPill( From a4be7159093f60c6397622e7d5095e9c7e272a85 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 18:19:24 +0200 Subject: [PATCH 58/89] fix(ui): drop the restart promise from the update tooltip --- core/app/components/Panels.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index bf4fbcf..27a35c0 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -104,7 +104,7 @@ export function UpdatePill({ const actionable = phase === 'available' || phase === 'error'; const title = phase === 'available' - ? `Update Codiff${version ? ` v${currentVersion} -> v${version}` : ''}. Downloads the update and restarts the app.` + ? `Update Codiff${version ? ` v${currentVersion} -> v${version}` : ''}. Downloads and installs the update.` : phase === 'updating' ? version ? `Updating to Codiff ${version}…` From 12a95af21900d5e7c117003f0b61611cc59bdad3 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 18:20:26 +0200 Subject: [PATCH 59/89] style(ui): center the update pill and size it to its label --- core/App.css | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/core/App.css b/core/App.css index f4c2308..0473143 100644 --- a/core/App.css +++ b/core/App.css @@ -1131,7 +1131,9 @@ a.review-top-bar-source:focus-visible { .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; } @@ -1141,21 +1143,21 @@ a.review-top-bar-source:focus-visible { animation: update-pill-in 360ms cubic-bezier(0.2, 0.82, 0.22, 1) both; background: color-mix(in srgb, var(--update-accent) 13%, transparent); border: 1px solid color-mix(in srgb, var(--update-accent) 36%, transparent); - border-radius: 10px; + border-radius: 999px; color: color-mix(in srgb, var(--update-accent) 68%, var(--text)); - corner-shape: squircle; cursor: pointer; display: flex; font: inherit; - font-size: 12.5px; + font-size: 12px; font-weight: 700; gap: 6px; + justify-content: center; line-height: 1; - padding: 7px 10px; + max-width: 100%; + padding: 6px 14px; transition: background 160ms ease, border-color 160ms ease; - width: 100%; } .update-pill:hover:not(:disabled) { From 6a6f1f68602542d8c3608169347d42eb8e00c095 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 18:27:49 +0200 Subject: [PATCH 60/89] test(update): expect strategy-aware tooltips on the update pill --- core/__tests__/UpdatePill.test.tsx | 28 ++++++++++++++++++++++++++-- core/types.ts | 1 + electron/__tests__/updater.test.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/core/__tests__/UpdatePill.test.tsx b/core/__tests__/UpdatePill.test.tsx index a2a6cb6..29ff5f7 100644 --- a/core/__tests__/UpdatePill.test.tsx +++ b/core/__tests__/UpdatePill.test.tsx @@ -59,13 +59,37 @@ test('shows a single Update button when an update is available', async () => { expect(view.container.querySelector('.update-popover')).toBeNull(); }); -test('does not promise a restart the download strategy cannot deliver', async () => { +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('promises nothing beyond the download without a known strategy', async () => { await using view = await renderPill( , ); - expect(pill(view)?.title).toContain('Downloads and installs'); + expect(pill(view)?.title).toContain('Downloads the update.'); expect(pill(view)?.title).not.toContain('restarts'); + expect(pill(view)?.title).not.toContain('opens'); }); test('applies the update with a single click', async () => { diff --git a/core/types.ts b/core/types.ts index 8be2687..844845f 100644 --- a/core/types.ts +++ b/core/types.ts @@ -769,6 +769,7 @@ export type CodiffUpdateStatus = { currentVersion: string; message?: string; phase: CodiffUpdatePhase; + strategy?: 'download' | 'squirrel'; version?: string; }; diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index cf3138b..dfe334f 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -13,6 +13,7 @@ type UpdateStatus = { currentVersion: string; message?: string; phase: 'available' | 'error' | 'idle' | 'installerReady' | 'updating'; + strategy?: 'download' | 'squirrel'; version?: string; }; @@ -160,6 +161,35 @@ test('starts from the cached state without hitting the network', async () => { }); }); +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) => { From 8fdf69fa188c138ea61ae9c6bba57168c8632be3 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 18:31:29 +0200 Subject: [PATCH 61/89] fix(updater): expose the update strategy so the pill can describe the hand-off --- core/app/components/Panels.tsx | 10 ++- electron/__tests__/updater.test.ts | 120 ++++++++++++++++++++++++----- electron/updater.cjs | 7 +- 3 files changed, 113 insertions(+), 24 deletions(-) diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index 27a35c0..91669a8 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -95,16 +95,22 @@ export function UpdatePill({ onApply: () => void; status: CodiffUpdateStatus; }) { - const { currentVersion, message, phase, version } = status; + 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.' + : 'Downloads the update.'; const title = phase === 'available' - ? `Update Codiff${version ? ` v${currentVersion} -> v${version}` : ''}. Downloads and installs the update.` + ? `Update Codiff${version ? ` v${currentVersion} -> v${version}` : ''}. ${applyEffect}` : phase === 'updating' ? version ? `Updating to Codiff ${version}…` diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index dfe334f..d748d8b 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -157,6 +157,7 @@ test('starts from the cached state without hitting the network', async () => { expect(updater.getStatus()).toEqual({ currentVersion: '1.9.2', phase: 'available', + strategy: 'squirrel', version: '1.9.3', }); }); @@ -211,7 +212,12 @@ test('checkForUpdates fetches, persists state and reports an available update', const status = await updater.checkForUpdates(); - expect(status).toEqual({ currentVersion: '1.9.2', phase: 'available', version: '1.9.3' }); + expect(status).toEqual({ + currentVersion: '1.9.2', + phase: 'available', + strategy: 'squirrel', + version: '1.9.3', + }); expect(notifications).toEqual([status]); const persisted = JSON.parse( @@ -237,7 +243,11 @@ test('checkForUpdates stays idle when already up to date', async () => { strategy: 'squirrel', }); - expect(await updater.checkForUpdates()).toEqual({ currentVersion: '1.9.2', phase: 'idle' }); + expect(await updater.checkForUpdates()).toEqual({ + currentVersion: '1.9.2', + phase: 'idle', + strategy: 'squirrel', + }); }); test('checkForUpdates honors the throttle and force bypasses it', async () => { @@ -266,11 +276,21 @@ test('checkForUpdates honors the throttle and force bypasses it', async () => { const throttled = await updater.checkForUpdates(); expect(requests).toBe(0); - expect(throttled).toEqual({ currentVersion: '1.9.2', phase: 'available', version: '1.9.3' }); + 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', version: '1.9.4' }); + 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 () => { @@ -300,7 +320,12 @@ test('checkForUpdates swallows network failures and keeps the cached state', asy const status = await updater.checkForUpdates(); - expect(status).toEqual({ currentVersion: '1.9.2', phase: 'available', version: '1.9.3' }); + expect(status).toEqual({ + currentVersion: '1.9.2', + phase: 'available', + strategy: 'squirrel', + version: '1.9.3', + }); expect(log.length).toBe(1); const persisted = JSON.parse( @@ -330,6 +355,7 @@ test('checkForUpdates rejects on failure only when forced', async () => { await expect(updater.checkForUpdates()).resolves.toEqual({ currentVersion: '1.9.2', phase: 'idle', + strategy: 'squirrel', }); await expect(updater.checkForUpdates({ force: true })).rejects.toThrow(); }); @@ -352,7 +378,11 @@ test('checkForUpdates does nothing for unpackaged builds', async () => { strategy: 'squirrel', }); - expect(await updater.checkForUpdates()).toEqual({ currentVersion: '1.9.2', phase: 'idle' }); + expect(await updater.checkForUpdates()).toEqual({ + currentVersion: '1.9.2', + phase: 'idle', + strategy: 'squirrel', + }); expect(requests).toBe(0); }); @@ -374,8 +404,12 @@ test('dismissUpdate persists the dismissal and hides the update', async () => { strategy: 'squirrel', }); - expect(updater.dismissUpdate()).toEqual({ currentVersion: '1.9.2', phase: 'idle' }); - expect(notifications).toEqual([{ currentVersion: '1.9.2', phase: 'idle' }]); + 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'), @@ -405,7 +439,12 @@ test('applyUpdate drives Squirrel through download and restart', async () => { const status = await updater.applyUpdate(); - expect(status).toEqual({ currentVersion: '1.9.2', phase: 'updating', version: '1.9.3' }); + 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', }); @@ -512,7 +551,12 @@ test('applyUpdate downloads and opens the installer for download installs', asyn const status = await updater.applyUpdate(); - expect(status).toEqual({ currentVersion: '1.9.2', phase: 'installerReady', version: '1.9.3' }); + 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'); }); @@ -664,7 +708,7 @@ test('a dismissal during an in-flight check is not erased', async () => { releaseResponse(); const status = await check; - expect(status).toEqual({ currentVersion: '1.9.2', phase: 'idle' }); + expect(status).toEqual({ currentVersion: '1.9.2', phase: 'idle', strategy: 'squirrel' }); const persisted = JSON.parse( await readFile(join(directory.path, 'update-state.json'), 'utf8'), @@ -697,7 +741,12 @@ test('a forced check clears the dismissal and resurfaces the update', async () = const status = await updater.checkForUpdates({ force: true }); - expect(status).toEqual({ currentVersion: '1.9.2', phase: 'available', version: '1.9.3' }); + 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'), @@ -741,12 +790,18 @@ test('concurrent checks run one at a time and the newest result wins', async () 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', version: '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'), @@ -874,6 +929,7 @@ test('a forced check failure belongs only to its own caller', async () => { await expect(second).resolves.toEqual({ currentVersion: '1.9.2', phase: 'available', + strategy: 'squirrel', version: '1.9.4', }); expect(log.length).toBe(1); @@ -911,10 +967,16 @@ test('a queued check cannot discard a successful forced result', async () => { pending[0]('1.9.4'); - expect(await forced).toEqual({ currentVersion: '1.9.2', phase: 'available', version: '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); @@ -1042,7 +1104,7 @@ test('a dismissal made after a forced check was queued survives it', async () => await waitFor(() => pending.length === 2); pending[1]('1.9.3'); - expect(await forced).toEqual({ currentVersion: '1.9.2', phase: 'idle' }); + 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'), @@ -1187,7 +1249,12 @@ test('applyLatest force-checks and applies in one step', async () => { const status = await updater.applyLatest(); - expect(status).toEqual({ currentVersion: '1.9.2', phase: 'updating', version: '1.9.3' }); + 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', }); @@ -1213,7 +1280,11 @@ test('applyLatest stays idle when already up to date', async () => { strategy: 'squirrel', }); - expect(await updater.applyLatest()).toEqual({ currentVersion: '1.9.2', phase: 'idle' }); + expect(await updater.applyLatest()).toEqual({ + currentVersion: '1.9.2', + phase: 'idle', + strategy: 'squirrel', + }); expect(autoUpdater.feedURL).toBeNull(); expect(autoUpdater.checkForUpdatesCalls).toBe(0); }); @@ -1329,7 +1400,12 @@ test('an older failed applyLatest defers to a newer successful one', async () => pending[1]({ version: '1.9.4' }); const status = await second; - expect(status).toEqual({ currentVersion: '1.9.2', phase: 'updating', version: '1.9.4' }); + 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); }); @@ -1371,10 +1447,16 @@ test('a newer applyLatest owns the apply over an older successful one', async () pending[1]('1.9.4'); const status = await second; - expect(status).toEqual({ currentVersion: '1.9.2', phase: 'updating', version: '1.9.4' }); + 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); diff --git a/electron/updater.cjs b/electron/updater.cjs index 265bcce..dc2ca3f 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -19,6 +19,7 @@ const { * currentVersion: string; * message?: string; * phase: UpdatePhase; + * strategy?: 'download' | 'squirrel'; * version?: string; * }} UpdateStatus * @typedef {{ @@ -100,8 +101,8 @@ const createUpdater = ({ const statusFromState = () => { const update = getAvailableUpdate(readUpdateState(configDir), currentVersion); return update - ? { currentVersion, phase: 'available', version: update.version } - : { currentVersion, phase: 'idle' }; + ? { currentVersion, phase: 'available', strategy, version: update.version } + : { currentVersion, phase: 'idle', strategy }; }; /** @type {UpdateStatus} */ @@ -124,7 +125,7 @@ const createUpdater = ({ return status; } - status = next; + status = { ...next, strategy }; onStatusChange?.({ ...status }); return status; }; From bcd3dee387bb2cc201720d7b78a43fc3eb481a8f Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:11:39 +0200 Subject: [PATCH 62/89] test(updater): expect Linux assets matched to the running architecture --- electron/__tests__/updater.test.ts | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index d748d8b..d74c23f 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -138,6 +138,43 @@ test('pickReleaseAsset matches the platform-specific artifact', () => { 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, { From 4c1f8f3005b996b9aa105e647bb5af7e94c339dd Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:12:05 +0200 Subject: [PATCH 63/89] fix(updater): match Linux release assets to the running architecture --- electron/updater.cjs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/electron/updater.cjs b/electron/updater.cjs index dc2ca3f..51269a7 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -37,6 +37,13 @@ const { const resolveUpdateStrategy = ({ hasSquirrelUpdateExe, platform }) => platform === 'darwin' || (platform === 'win32' && hasSquirrelUpdateExe) ? 'squirrel' : '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 @@ -57,7 +64,22 @@ const pickReleaseAsset = (assets, { arch, linuxFlavor, platform }) => { } if (platform === 'linux' && linuxFlavor) { - return find(({ name }) => name.endsWith(`.${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; From e76f4fd91e98ed2053d9ea4307ea9bc3de953306 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:13:14 +0200 Subject: [PATCH 64/89] test(update): expect plain Windows installs to hand off to the release page --- core/__tests__/UpdatePill.test.tsx | 13 ++++++ core/types.ts | 2 +- electron/__tests__/updater.test.ts | 69 +++++++++++++++++++++++++++--- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/core/__tests__/UpdatePill.test.tsx b/core/__tests__/UpdatePill.test.tsx index 29ff5f7..a127513 100644 --- a/core/__tests__/UpdatePill.test.tsx +++ b/core/__tests__/UpdatePill.test.tsx @@ -82,6 +82,19 @@ test('describes the hand-off for the download strategy', async () => { 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( , diff --git a/core/types.ts b/core/types.ts index 844845f..0f4f9b8 100644 --- a/core/types.ts +++ b/core/types.ts @@ -769,7 +769,7 @@ export type CodiffUpdateStatus = { currentVersion: string; message?: string; phase: CodiffUpdatePhase; - strategy?: 'download' | 'squirrel'; + strategy?: 'download' | 'manual' | 'squirrel'; version?: string; }; diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index d74c23f..d78d6df 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -13,7 +13,7 @@ type UpdateStatus = { currentVersion: string; message?: string; phase: 'available' | 'error' | 'idle' | 'installerReady' | 'updating'; - strategy?: 'download' | 'squirrel'; + strategy?: 'download' | 'manual' | 'squirrel'; version?: string; }; @@ -39,10 +39,11 @@ const { createUpdater, pickReleaseAsset, resolveUpdateStrategy } = require('../u 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' | 'squirrel'; + strategy: 'download' | 'manual' | 'squirrel'; }) => Updater; pickReleaseAsset: ( assets: ReadonlyArray, @@ -51,7 +52,7 @@ const { createUpdater, pickReleaseAsset, resolveUpdateStrategy } = require('../u resolveUpdateStrategy: (options: { hasSquirrelUpdateExe: boolean; platform: string; - }) => 'download' | 'squirrel'; + }) => 'download' | 'manual' | 'squirrel'; }; class FakeAutoUpdater extends EventEmitter { @@ -107,9 +108,7 @@ test('resolveUpdateStrategy uses Squirrel on macOS and Squirrel-installed Window 'squirrel', ); expect(resolveUpdateStrategy({ hasSquirrelUpdateExe: true, platform: 'win32' })).toBe('squirrel'); - expect(resolveUpdateStrategy({ hasSquirrelUpdateExe: false, platform: 'win32' })).toBe( - 'download', - ); + expect(resolveUpdateStrategy({ hasSquirrelUpdateExe: false, platform: 'win32' })).toBe('manual'); expect(resolveUpdateStrategy({ hasSquirrelUpdateExe: false, platform: 'linux' })).toBe( 'download', ); @@ -598,6 +597,64 @@ test('applyUpdate downloads and opens the installer for download installs', asyn 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-'); From 5074f31cc7329ff47543e61162b56222dc376d4e Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:13:55 +0200 Subject: [PATCH 65/89] fix(updater): hand plain Windows installs off to the release page --- core/app/components/Panels.tsx | 4 +++- electron/main.cjs | 1 + electron/updater.cjs | 42 ++++++++++++++++++++++++++++++---- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index 91669a8..09e1ddd 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -107,7 +107,9 @@ export function UpdatePill({ ? 'Downloads the update and restarts the app.' : strategy === 'download' ? 'Downloads and opens the update. Quit Codiff to finish installing.' - : 'Downloads the update.'; + : 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}` diff --git a/electron/main.cjs b/electron/main.cjs index 454d49e..f05a06a 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1235,6 +1235,7 @@ const initUpdater = () => { 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({ diff --git a/electron/updater.cjs b/electron/updater.cjs index 51269a7..3e0752e 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -6,6 +6,7 @@ const { fetchLatestRelease, getAvailableUpdate, readUpdateState, + releasePageUrl, shouldCheckForUpdates, updateFeedUrl, writeUpdateState, @@ -15,11 +16,12 @@ const { * @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?: 'download' | 'squirrel'; + * strategy?: UpdateStrategy; * version?: string; * }} UpdateStatus * @typedef {{ @@ -32,10 +34,16 @@ const { /** * @param {{ hasSquirrelUpdateExe: boolean; platform: string }} options - * @returns {'download' | 'squirrel'} + * @returns {UpdateStrategy} */ const resolveUpdateStrategy = ({ hasSquirrelUpdateExe, platform }) => - platform === 'darwin' || (platform === 'win32' && hasSquirrelUpdateExe) ? 'squirrel' : 'download'; + 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. @@ -96,10 +104,11 @@ const pickReleaseAsset = (assets, { arch, linuxFlavor, platform }) => { * 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' | 'squirrel'; + * strategy: UpdateStrategy; * }} options */ const createUpdater = ({ @@ -112,6 +121,7 @@ const createUpdater = ({ linuxFlavor, log, onStatusChange, + openExternal, openPath, platform, releaseUrl, @@ -313,6 +323,24 @@ const createUpdater = ({ } }; + const applyManualUpdate = async () => { + const version = status.version; + if (!openExternal) { + return setError('The updater is unavailable in this build.', version); + } + + try { + await openExternal( + version ? releasePageUrl(version) : 'https://github.com/nkzw-tech/codiff/releases', + ); + // Nothing was installed; the update stays available until the user + // replaces the app themselves. + return { ...status }; + } catch (error) { + return setError(error instanceof Error ? error.message : String(error), version); + } + }; + const applyUpdate = async () => { if (status.phase !== 'available' && status.phase !== 'error') { return { ...status }; @@ -320,7 +348,11 @@ const createUpdater = ({ actionGeneration++; return { - ...(strategy === 'squirrel' ? applySquirrelUpdate() : await applyDownloadUpdate()), + ...(strategy === 'squirrel' + ? applySquirrelUpdate() + : strategy === 'manual' + ? await applyManualUpdate() + : await applyDownloadUpdate()), }; }; From d99f97d769e50fd675c077986f9512f49f3754ed Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:15:01 +0200 Subject: [PATCH 66/89] test(update): expect downloaded installers verified against release digests --- electron/__tests__/update-check.test.ts | 2 + electron/__tests__/updater.test.ts | 122 +++++++++++++++++++++++- 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/electron/__tests__/update-check.test.ts b/electron/__tests__/update-check.test.ts index 179eac6..75a472e 100644 --- a/electron/__tests__/update-check.test.ts +++ b/electron/__tests__/update-check.test.ts @@ -197,6 +197,7 @@ test('fetchLatestRelease parses the release and sends a User-Agent', async () => { 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', }, ], @@ -212,6 +213,7 @@ test('fetchLatestRelease parses the release and sends a User-Agent', async () => 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', }, diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index d78d6df..1cc46e4 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -1,4 +1,6 @@ +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'; @@ -25,7 +27,7 @@ type Updater = { getStatus: () => UpdateStatus; }; -type ReleaseAsset = { name: string; url: string }; +type ReleaseAsset = { digest?: string; name: string; url: string }; const require = createRequire(import.meta.url); const { createUpdater, pickReleaseAsset, resolveUpdateStrategy } = require('../updater.cjs') as { @@ -93,6 +95,8 @@ const writeState = async ( 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()) { @@ -562,6 +566,7 @@ test('applyUpdate downloads and opens the installer for download installs', asyn releaseJson('1.9.3', [ { browser_download_url: `${origin}/asset.deb`, + digest: sha256('deb-bytes'), name: 'codiff_1.9.3_amd64.deb', }, ]), @@ -702,7 +707,11 @@ test('concurrent applyUpdate calls download the installer only once', async () = 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' }, + { + browser_download_url: `${origin}/asset.deb`, + digest: sha256('deb-bytes'), + name: 'codiff_1.9.3_amd64.deb', + }, ]), ); }); @@ -730,7 +739,58 @@ test('concurrent applyUpdate calls download the installer only once', async () = expect(opened.length).toBe(1); }); -test('applyUpdate reports an error when the installer cannot be opened', async () => { +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, { @@ -752,6 +812,56 @@ test('applyUpdate reports an error when the installer cannot be opened', async ( ); }); + 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, @@ -1267,7 +1377,11 @@ test('checks leave a handed-off installer alone until relaunch', async () => { 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' }, + { + browser_download_url: `${origin}/asset.deb`, + digest: sha256('deb-bytes'), + name: 'codiff_1.9.3_amd64.deb', + }, ]), ); }); From 362a58efe66d176cd17f89e3a064eae4120749fb Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:15:40 +0200 Subject: [PATCH 67/89] fix(updater): stream downloads and verify them against the release digest --- electron/update-check.cjs | 10 ++++++++-- electron/updater.cjs | 39 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/electron/update-check.cjs b/electron/update-check.cjs index c4d5e82..dd5d9a2 100644 --- a/electron/update-check.cjs +++ b/electron/update-check.cjs @@ -16,7 +16,7 @@ const getDefaultConfigDir = () => join(homedir(), '.codiff'); * latestVersion: string; * dismissedVersion?: string; * }} UpdateState - * @typedef {{ name: string; url: string }} ReleaseAsset + * @typedef {{ digest?: string; name: string; url: string }} ReleaseAsset */ /** @@ -178,7 +178,13 @@ const fetchLatestRelease = async (url) => { asset !== null && typeof asset.name === 'string' && typeof asset.browser_download_url === 'string' - ? [{ name: asset.name, url: asset.browser_download_url }] + ? [ + { + ...(typeof asset.digest === 'string' ? { digest: asset.digest } : {}), + name: asset.name, + url: asset.browser_download_url, + }, + ] : [], ) : []; diff --git a/electron/updater.cjs b/electron/updater.cjs index 3e0752e..04b38d0 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -1,7 +1,11 @@ // @ts-check -const { writeFileSync } = require('node:fs'); +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, @@ -305,13 +309,42 @@ const createUpdater = ({ 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) { + if (!response.ok || !response.body) { throw new Error(`Downloading the update failed with status ${response.status}.`); } const path = join(downloadDirectory, asset.name); - writeFileSync(path, Buffer.from(await response.arrayBuffer())); + 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); From a2deba047f412322d6e8f069dc2378a20150e18d Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:17:30 +0200 Subject: [PATCH 68/89] test(update): expect codiff update to hand brew installs to the app --- bin/update-command.js | 10 ++-- core/__tests__/codiff-update-command.test.ts | 58 +------------------- 2 files changed, 8 insertions(+), 60 deletions(-) diff --git a/bin/update-command.js b/bin/update-command.js index 6fdc1e3..f53b656 100644 --- a/bin/update-command.js +++ b/bin/update-command.js @@ -11,7 +11,7 @@ const { * Decide what `codiff update` should do for this install. * * @param {{ - * brewOwnsCask: () => boolean; + * brewOwnsCask?: () => boolean; * currentVersion: string; * isSourceCheckout: boolean; * latestVersion: string; @@ -32,7 +32,7 @@ export function resolveUpdateAction({ return { kind: 'source-checkout', version: latestVersion }; } - return brewOwnsCask() + return /** @type {() => boolean} */ (brewOwnsCask)() ? { kind: 'brew-upgrade', version: latestVersion } : { kind: 'open-app', version: latestVersion }; } @@ -41,13 +41,13 @@ export function resolveUpdateAction({ * Check GitHub Releases and perform the platform-appropriate update. * * @param {{ - * brewOwnsCask: () => boolean; + * brewOwnsCask?: () => boolean; * currentVersion: string; * isSourceCheckout: boolean; * log: (line: string) => void; * openApp: (() => void) | null; * releaseUrl?: string; - * runBrewUpgrade: () => number; + * runBrewUpgrade?: () => number; * }} options * @returns {Promise} */ @@ -90,7 +90,7 @@ export async function runUpdateCommand({ return 0; case 'brew-upgrade': log(`Updating Codiff v${currentVersion} -> v${action.version} via Homebrew…`); - return runBrewUpgrade(); + return /** @type {() => number} */ (runBrewUpgrade)(); case 'open-app': if (openApp) { log(`Opening Codiff to install v${action.version}…`); diff --git a/core/__tests__/codiff-update-command.test.ts b/core/__tests__/codiff-update-command.test.ts index a14c263..acc449f 100644 --- a/core/__tests__/codiff-update-command.test.ts +++ b/core/__tests__/codiff-update-command.test.ts @@ -14,14 +14,9 @@ const startReleaseServer = async (version: string, statusCode = 200) => { return { disposable, url: `http://127.0.0.1:${port}/` }; }; -const brewOwnsCask = () => { - throw new Error('Must not probe brew when up to date.'); -}; - test('resolveUpdateAction reports up to date for equal or older releases', () => { expect( resolveUpdateAction({ - brewOwnsCask, currentVersion: '1.9.2', isSourceCheckout: false, latestVersion: '1.9.2', @@ -29,7 +24,6 @@ test('resolveUpdateAction reports up to date for equal or older releases', () => ).toEqual({ kind: 'up-to-date' }); expect( resolveUpdateAction({ - brewOwnsCask, currentVersion: '2.0.0', isSourceCheckout: false, latestVersion: '1.9.2', @@ -40,7 +34,6 @@ test('resolveUpdateAction reports up to date for equal or older releases', () => test('resolveUpdateAction prefers source-checkout guidance over everything', () => { expect( resolveUpdateAction({ - brewOwnsCask: () => true, currentVersion: '1.9.2', isSourceCheckout: true, latestVersion: '1.9.3', @@ -48,21 +41,11 @@ test('resolveUpdateAction prefers source-checkout guidance over everything', () ).toEqual({ kind: 'source-checkout', version: '1.9.3' }); }); -test('resolveUpdateAction upgrades brew-owned installs through brew', () => { +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({ - brewOwnsCask: () => true, - currentVersion: '1.9.2', - isSourceCheckout: false, - latestVersion: '1.9.3', - }), - ).toEqual({ kind: 'brew-upgrade', version: '1.9.3' }); -}); - -test('resolveUpdateAction hands other installs to the app', () => { - expect( - resolveUpdateAction({ - brewOwnsCask: () => false, currentVersion: '1.9.2', isSourceCheckout: false, latestVersion: '1.9.3', @@ -75,7 +58,6 @@ test('runUpdateCommand reports up to date without touching brew or the app', asy const lines: Array = []; const exitCode = await runUpdateCommand({ - brewOwnsCask: () => false, currentVersion: '1.9.2', isSourceCheckout: false, log: (line) => lines.push(line), @@ -83,43 +65,18 @@ test('runUpdateCommand reports up to date without touching brew or the app', asy throw new Error('Must not open the app.'); }, releaseUrl: url, - runBrewUpgrade: () => { - throw new Error('Must not run brew.'); - }, }); expect(exitCode).toBe(0); expect(lines.join('\n')).toContain('up to date'); }); -test('runUpdateCommand runs brew for brew-owned installs and returns its exit code', async () => { - const { disposable: _server, url } = await startReleaseServer('1.9.3'); - let upgrades = 0; - - const exitCode = await runUpdateCommand({ - brewOwnsCask: () => true, - currentVersion: '1.9.2', - isSourceCheckout: false, - log: () => {}, - openApp: () => {}, - releaseUrl: url, - runBrewUpgrade: () => { - upgrades++; - return 0; - }, - }); - - expect(exitCode).toBe(0); - expect(upgrades).toBe(1); -}); - 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({ - brewOwnsCask: () => false, currentVersion: '1.9.2', isSourceCheckout: false, log: (line) => lines.push(line), @@ -127,7 +84,6 @@ test('runUpdateCommand opens the app for non-brew installs', async () => { opened++; }, releaseUrl: url, - runBrewUpgrade: () => 1, }); expect(exitCode).toBe(0); @@ -140,13 +96,11 @@ test('runUpdateCommand prints manual guidance when the app cannot be opened', as const lines: Array = []; const exitCode = await runUpdateCommand({ - brewOwnsCask: () => false, currentVersion: '1.9.2', isSourceCheckout: false, log: (line) => lines.push(line), openApp: null, releaseUrl: url, - runBrewUpgrade: () => 1, }); expect(exitCode).toBe(1); @@ -158,7 +112,6 @@ test('runUpdateCommand guides source checkouts instead of updating', async () => const lines: Array = []; const exitCode = await runUpdateCommand({ - brewOwnsCask: () => true, currentVersion: '1.9.2', isSourceCheckout: true, log: (line) => lines.push(line), @@ -166,9 +119,6 @@ test('runUpdateCommand guides source checkouts instead of updating', async () => throw new Error('Must not open the app.'); }, releaseUrl: url, - runBrewUpgrade: () => { - throw new Error('Must not run brew.'); - }, }); expect(exitCode).toBe(0); @@ -180,13 +130,11 @@ test('runUpdateCommand fails cleanly when the release check fails', async () => const lines: Array = []; const exitCode = await runUpdateCommand({ - brewOwnsCask: () => false, currentVersion: '1.9.2', isSourceCheckout: false, log: (line) => lines.push(line), openApp: () => {}, releaseUrl: url, - runBrewUpgrade: () => 1, }); expect(exitCode).toBe(1); From b46d9aec474ecca43a46bf103bd6ea5c71261326 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:18:25 +0200 Subject: [PATCH 69/89] fix(update): drop the brew special case from codiff update --- bin/codiff.js | 19 +------------------ bin/update-command.js | 36 +++++++++--------------------------- 2 files changed, 10 insertions(+), 45 deletions(-) diff --git a/bin/codiff.js b/bin/codiff.js index 8ee78a7..8e099ae 100755 --- a/bin/codiff.js +++ b/bin/codiff.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { execFileSync, spawn } from 'node:child_process'; +import { spawn } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import http from 'node:http'; import https from 'node:https'; @@ -110,14 +110,6 @@ const buildWalkthroughGuide = () => { const runCodiffUpdate = () => { const appBundleMatch = process.platform === 'darwin' ? root.match(/^(.*\.app)\//) : null; return runUpdateCommand({ - brewOwnsCask: () => { - try { - execFileSync('brew', ['list', '--cask', 'codiff'], { stdio: 'ignore' }); - return true; - } catch { - return false; - } - }, currentVersion: packageJson.version, isSourceCheckout: existsSync(resolve(root, '.git')), log: (line) => process.stdout.write(`${line}\n`), @@ -129,15 +121,6 @@ const runCodiffUpdate = () => { }).unref(); } : null, - runBrewUpgrade: () => { - try { - execFileSync('brew', ['upgrade', '--cask', 'codiff'], { stdio: 'inherit' }); - return 0; - } catch (error) { - const status = /** @type {{ status?: unknown }} */ (error)?.status; - return typeof status === 'number' ? status : 1; - } - }, }); }; diff --git a/bin/update-command.js b/bin/update-command.js index f53b656..2839942 100644 --- a/bin/update-command.js +++ b/bin/update-command.js @@ -10,30 +10,24 @@ const { /** * 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 {{ - * brewOwnsCask?: () => boolean; * currentVersion: string; * isSourceCheckout: boolean; * latestVersion: string; * }} options - * @returns {{ kind: 'up-to-date' } | { kind: 'brew-upgrade' | 'open-app' | 'source-checkout'; version: string }} + * @returns {{ kind: 'up-to-date' } | { kind: 'open-app' | 'source-checkout'; version: string }} */ -export function resolveUpdateAction({ - brewOwnsCask, - currentVersion, - isSourceCheckout, - latestVersion, -}) { +export function resolveUpdateAction({ currentVersion, isSourceCheckout, latestVersion }) { if (!isNewerVersion(latestVersion, currentVersion)) { return { kind: 'up-to-date' }; } - if (isSourceCheckout) { - return { kind: 'source-checkout', version: latestVersion }; - } - - return /** @type {() => boolean} */ (brewOwnsCask)() - ? { kind: 'brew-upgrade', version: latestVersion } + return isSourceCheckout + ? { kind: 'source-checkout', version: latestVersion } : { kind: 'open-app', version: latestVersion }; } @@ -41,24 +35,20 @@ export function resolveUpdateAction({ * Check GitHub Releases and perform the platform-appropriate update. * * @param {{ - * brewOwnsCask?: () => boolean; * currentVersion: string; * isSourceCheckout: boolean; * log: (line: string) => void; * openApp: (() => void) | null; * releaseUrl?: string; - * runBrewUpgrade?: () => number; * }} options * @returns {Promise} */ export async function runUpdateCommand({ - brewOwnsCask, currentVersion, isSourceCheckout, log, openApp, releaseUrl, - runBrewUpgrade, }) { let latestVersion; try { @@ -72,12 +62,7 @@ export async function runUpdateCommand({ return 1; } - const action = resolveUpdateAction({ - brewOwnsCask, - currentVersion, - isSourceCheckout, - latestVersion, - }); + const action = resolveUpdateAction({ currentVersion, isSourceCheckout, latestVersion }); switch (action.kind) { case 'up-to-date': @@ -88,9 +73,6 @@ export async function runUpdateCommand({ `Codiff v${action.version} is available, but this is a source checkout. Run git pull and rebuild instead.`, ); return 0; - case 'brew-upgrade': - log(`Updating Codiff v${currentVersion} -> v${action.version} via Homebrew…`); - return /** @type {() => number} */ (runBrewUpgrade)(); case 'open-app': if (openApp) { log(`Opening Codiff to install v${action.version}…`); From 94ef06615bf883d74a9b87acdcc11a7738d5c247 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:19:17 +0200 Subject: [PATCH 70/89] test(ui): expect the update pill to use the shared button styles --- core/__tests__/UpdatePill.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/__tests__/UpdatePill.test.tsx b/core/__tests__/UpdatePill.test.tsx index a127513..68e8342 100644 --- a/core/__tests__/UpdatePill.test.tsx +++ b/core/__tests__/UpdatePill.test.tsx @@ -105,6 +105,14 @@ test('promises nothing beyond the download without a known strategy', async () = 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( From 8b9d86a10730e7ef693bfd62e0161f56fd247146 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:20:56 +0200 Subject: [PATCH 71/89] fix(ui): render the update pill as a shared button with a solid accent --- core/App.css | 57 +++++++++++++--------------------- core/app/components/Panels.tsx | 4 +-- 2 files changed, 23 insertions(+), 38 deletions(-) diff --git a/core/App.css b/core/App.css index 0473143..363dea0 100644 --- a/core/App.css +++ b/core/App.css @@ -43,7 +43,7 @@ --text: #111111; --tree-selection-bg: #c2e8ff50; --tree-selection-focus: #3d87f5; - --update-accent: rgb(14 116 144); + --update-accent: rgb(93 120 199); --update-accent-contrast: #ffffff; --viewed: rgb(31 122 68); --font-mono: var(--codiff-font-mono); @@ -74,8 +74,8 @@ --text: rgb(230 230 230); --tree-selection-bg: #a5a5a540; --tree-selection-focus: #3d87f5; - --update-accent: rgb(34 211 238); - --update-accent-contrast: rgb(12 41 51); + --update-accent: rgb(93 120 199); + --update-accent-contrast: #ffffff; --viewed: rgb(111 208 148); color-scheme: dark; } @@ -105,7 +105,7 @@ --text: #111111; --tree-selection-bg: #c2e8ff50; --tree-selection-focus: #3d87f5; - --update-accent: rgb(14 116 144); + --update-accent: rgb(93 120 199); --update-accent-contrast: #ffffff; --viewed: rgb(31 122 68); color-scheme: light; @@ -135,8 +135,8 @@ --text: rgb(230 230 230); --tree-selection-bg: #a5a5a540; --tree-selection-focus: #3d87f5; - --update-accent: rgb(34 211 238); - --update-accent-contrast: rgb(12 41 51); + --update-accent: rgb(93 120 199); + --update-accent-contrast: #ffffff; --viewed: rgb(111 208 148); color-scheme: dark; } @@ -1138,52 +1138,37 @@ a.review-top-bar-source:focus-visible { position: relative; } -.update-pill { - align-items: center; +/* 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: color-mix(in srgb, var(--update-accent) 13%, transparent); - border: 1px solid color-mix(in srgb, var(--update-accent) 36%, transparent); - border-radius: 999px; - color: color-mix(in srgb, var(--update-accent) 68%, var(--text)); - cursor: pointer; - display: flex; - font: inherit; - font-size: 12px; - font-weight: 700; - gap: 6px; - justify-content: center; - line-height: 1; + 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%; - padding: 6px 14px; - transition: - background 160ms ease, - border-color 160ms ease; } -.update-pill:hover:not(:disabled) { - background: color-mix(in srgb, var(--update-accent) 21%, transparent); - border-color: color-mix(in srgb, var(--update-accent) 52%, transparent); +.update-pill.codiff-button:hover:not(:disabled) { + background: color-mix(in srgb, var(--update-accent) 88%, #ffffff); } -.update-pill:disabled { +.update-pill.codiff-button:disabled { cursor: default; } -.update-pill.available { +.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.error { - background: color-mix(in srgb, var(--danger) 13%, transparent); - border-color: color-mix(in srgb, var(--danger) 36%, transparent); - color: color-mix(in srgb, var(--danger) 62%, var(--text)); +.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.error:hover:not(:disabled) { - background: color-mix(in srgb, var(--danger) 21%, transparent); - border-color: color-mix(in srgb, var(--danger) 52%, transparent); +.update-pill.codiff-button.error:hover:not(:disabled) { + background: color-mix(in srgb, var(--danger) 74%, #ffffff); } .update-pill-spinner { diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index 09e1ddd..ec6fbf5 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -123,7 +123,7 @@ export function UpdatePill({ return (
- +
); } From eb44229ada1f0753d5d366787e78c13210b806ba Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:21:27 +0200 Subject: [PATCH 72/89] test(ui): expect the update pill styles to stay out of the web stylesheet --- core/__tests__/UpdatePill.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/core/__tests__/UpdatePill.test.tsx b/core/__tests__/UpdatePill.test.tsx index 68e8342..1f0680d 100644 --- a/core/__tests__/UpdatePill.test.tsx +++ b/core/__tests__/UpdatePill.test.tsx @@ -1,5 +1,7 @@ // @vitest-environment jsdom +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { expect, test } from 'vite-plus/test'; @@ -105,6 +107,18 @@ test('promises nothing beyond the download without a known strategy', async () = expect(pill(view)?.title).not.toContain('opens'); }); +test('keeps the update pill styles out of the shared web stylesheet', () => { + // App.css ships to the web app; the update pill is desktop-only chrome and + // belongs in Desktop.css, which only the Electron entry imports. + const appCss = readFileSync(resolve('core/App.css'), 'utf8'); + const desktopCss = readFileSync(resolve('core/Desktop.css'), 'utf8'); + + expect(appCss).not.toContain('update-pill'); + expect(appCss).not.toContain('--update-accent'); + expect(desktopCss).toContain('.update-pill'); + expect(desktopCss).toContain('--update-accent'); +}); + test('styles the pill like the shared buttons', async () => { await using view = await renderPill( , From 849a50c1e8e6f30604dd9095d965c6d581aa9b2c Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:21:57 +0200 Subject: [PATCH 73/89] fix(ui): keep the update pill styles out of the shared web stylesheet --- core/App.css | 81 ------------------------------------------------ core/Desktop.css | 80 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 81 deletions(-) diff --git a/core/App.css b/core/App.css index 363dea0..2294569 100644 --- a/core/App.css +++ b/core/App.css @@ -43,8 +43,6 @@ --text: #111111; --tree-selection-bg: #c2e8ff50; --tree-selection-focus: #3d87f5; - --update-accent: rgb(93 120 199); - --update-accent-contrast: #ffffff; --viewed: rgb(31 122 68); --font-mono: var(--codiff-font-mono); --font-sans: var(--codiff-font-sans); @@ -74,8 +72,6 @@ --text: rgb(230 230 230); --tree-selection-bg: #a5a5a540; --tree-selection-focus: #3d87f5; - --update-accent: rgb(93 120 199); - --update-accent-contrast: #ffffff; --viewed: rgb(111 208 148); color-scheme: dark; } @@ -105,8 +101,6 @@ --text: #111111; --tree-selection-bg: #c2e8ff50; --tree-selection-focus: #3d87f5; - --update-accent: rgb(93 120 199); - --update-accent-contrast: #ffffff; --viewed: rgb(31 122 68); color-scheme: light; } @@ -135,8 +129,6 @@ --text: rgb(230 230 230); --tree-selection-bg: #a5a5a540; --tree-selection-focus: #3d87f5; - --update-accent: rgb(93 120 199); - --update-accent-contrast: #ffffff; --viewed: rgb(111 208 148); color-scheme: dark; } @@ -1128,79 +1120,6 @@ a.review-top-bar-source:focus-visible { font-weight: 700; } -.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) 88%, #ffffff); -} - -.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) 74%, #ffffff); -} - -.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-search-panel { -webkit-app-region: no-drag; align-items: center; diff --git a/core/Desktop.css b/core/Desktop.css index 37796b7..32b3ed7 100644 --- a/core/Desktop.css +++ b/core/Desktop.css @@ -61,3 +61,83 @@ 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 { + --update-accent: rgb(93 120 199); + --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) 88%, #ffffff); +} + +.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) 74%, #ffffff); +} + +.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; + } +} From 57aed65740a4fc4de96bff4e39a48ef2e9ffa2ad Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:27:27 +0200 Subject: [PATCH 74/89] test(updater): expect a manual retry to recover once the page opens --- electron/__tests__/updater.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index 1cc46e4..5a18a90 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -739,6 +739,36 @@ test('concurrent applyUpdate calls download the installer only once', async () = expect(opened.length).toBe(1); }); +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-'); From d60585137871f3d67c7cbfcba7a5eeab3ee66957 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:27:54 +0200 Subject: [PATCH 75/89] fix(updater): recover from a failed manual hand-off on retry --- electron/updater.cjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/electron/updater.cjs b/electron/updater.cjs index 04b38d0..a6132e5 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -367,8 +367,9 @@ const createUpdater = ({ version ? releasePageUrl(version) : 'https://github.com/nkzw-tech/codiff/releases', ); // Nothing was installed; the update stays available until the user - // replaces the app themselves. - return { ...status }; + // replaces the app themselves. Recomputing from state also clears a + // previous open failure once a retry reaches the release page. + return setStatus(statusFromState()); } catch (error) { return setError(error instanceof Error ? error.message : String(error), version); } From 8bff687373c7828959e1c30d041bfc270a3b73b5 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:37:49 +0200 Subject: [PATCH 76/89] style(ui): match the update accent to the Codex button blue --- core/Desktop.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/Desktop.css b/core/Desktop.css index 32b3ed7..715517a 100644 --- a/core/Desktop.css +++ b/core/Desktop.css @@ -65,7 +65,7 @@ /* 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 { - --update-accent: rgb(93 120 199); + --update-accent: rgb(92 119 197); --update-accent-contrast: #ffffff; } From 2ff68addb235792d50d6eaf7fe5a5090fb81472d Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:41:01 +0200 Subject: [PATCH 77/89] test(updater): expect manual applies serialized and yielding to dismissals --- electron/__tests__/updater.test.ts | 65 ++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index 5a18a90..0d8ed34 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -739,6 +739,71 @@ test('concurrent applyUpdate calls download the installer only once', async () = 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('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, { From cd63a212265b3dc24f910210fb0cf181f40f9a66 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:41:32 +0200 Subject: [PATCH 78/89] fix(updater): serialize manual applies and yield to newer actions --- electron/updater.cjs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/electron/updater.cjs b/electron/updater.cjs index a6132e5..5c4102d 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -356,27 +356,40 @@ const createUpdater = ({ } }; + // The manual strategy never leaves the available phase, so the phase guard + // in applyUpdate cannot serialize it; this latch keeps a double click from + // opening the release page twice while the first open is still in flight. + let manualOpenInFlight = false; + const applyManualUpdate = async () => { const version = status.version; if (!openExternal) { return setError('The updater is unavailable in this build.', version); } + manualOpenInFlight = true; + const generationAtStart = actionGeneration; try { await openExternal( version ? releasePageUrl(version) : 'https://github.com/nkzw-tech/codiff/releases', ); // 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. - return setStatus(statusFromState()); + // 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 setError(error instanceof Error ? error.message : String(error), version); + return actionGeneration === generationAtStart + ? setError(error instanceof Error ? error.message : String(error), version) + : { ...status }; + } finally { + manualOpenInFlight = false; } }; const applyUpdate = async () => { - if (status.phase !== 'available' && status.phase !== 'error') { + if (manualOpenInFlight || (status.phase !== 'available' && status.phase !== 'error')) { return { ...status }; } From 1191b3eb0a675b1ba1cdd9f357f4360f01a26d17 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:42:07 +0200 Subject: [PATCH 79/89] style(ui): darken the update accent and hover to clear WCAG AA --- core/Desktop.css | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/core/Desktop.css b/core/Desktop.css index 715517a..179dc6c 100644 --- a/core/Desktop.css +++ b/core/Desktop.css @@ -65,7 +65,9 @@ /* 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 { - --update-accent: rgb(92 119 197); + /* 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; } @@ -90,7 +92,7 @@ } .update-pill.codiff-button:hover:not(:disabled) { - background: color-mix(in srgb, var(--update-accent) 88%, #ffffff); + background: color-mix(in srgb, var(--update-accent) 90%, #000000); } .update-pill.codiff-button:disabled { @@ -109,7 +111,7 @@ } .update-pill.codiff-button.error:hover:not(:disabled) { - background: color-mix(in srgb, var(--danger) 74%, #ffffff); + background: color-mix(in srgb, var(--danger) 78%, #000000); } .update-pill-spinner { From 4a7cc20cca6b5461f0758b27b18bdedc10dd4924 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 21:53:56 +0200 Subject: [PATCH 80/89] test(updater): expect applyLatest to supersede a pending manual hand-off --- electron/__tests__/updater.test.ts | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index 0d8ed34..39ee5e3 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -773,6 +773,67 @@ test('concurrent manual applies open the release page only once', async () => { 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 wins over a pending manual failure', async () => { await using directory = await createTemporaryDirectory('codiff-updater-'); await writeState(directory.path, { From 24ce5ec0772f90af03ba5756bd1db64bf382f911 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 22:13:59 +0200 Subject: [PATCH 81/89] fix(updater): let newer manual requests supersede a pending hand-off --- electron/updater.cjs | 70 +++++++++++++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/electron/updater.cjs b/electron/updater.cjs index 5c4102d..829f376 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -357,42 +357,72 @@ const createUpdater = ({ }; // The manual strategy never leaves the available phase, so the phase guard - // in applyUpdate cannot serialize it; this latch keeps a double click from - // opening the release page twice while the first open is still in flight. - let manualOpenInFlight = false; + // 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 {{ 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); } - manualOpenInFlight = true; - const generationAtStart = actionGeneration; + const superseded = manualOpen; + 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; + } + + const generationAtStart = actionGeneration; + 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 = { promise: attempt, url }; try { - await openExternal( - version ? releasePageUrl(version) : 'https://github.com/nkzw-tech/codiff/releases', - ); - // 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 }; + return await attempt; } finally { - manualOpenInFlight = false; + if (manualOpen?.promise === attempt) { + manualOpen = null; + } } }; const applyUpdate = async () => { - if (manualOpenInFlight || (status.phase !== 'available' && status.phase !== 'error')) { + 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. + if (strategy === 'manual' && manualOpen?.url === manualUpdateUrl()) { + return { ...(await manualOpen.promise) }; + } + actionGeneration++; return { ...(strategy === 'squirrel' From 524ac39f4f07143cfd6080362979974a8267e001 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 22:22:34 +0200 Subject: [PATCH 82/89] test(updater): expect a dismissal during a queued manual hand-off to win --- electron/__tests__/updater.test.ts | 48 ++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index 39ee5e3..d239a7f 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -834,6 +834,54 @@ test('applyLatest supersedes a pending manual hand-off', async () => { 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 dismissal wins over a pending manual failure', async () => { await using directory = await createTemporaryDirectory('codiff-updater-'); await writeState(directory.path, { From 57f707f4132644b850563524778b75a6743b14d6 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 22:22:57 +0200 Subject: [PATCH 83/89] fix(updater): capture ownership before waiting on a superseded hand-off --- electron/updater.cjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/electron/updater.cjs b/electron/updater.cjs index 829f376..fce98f9 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -378,13 +378,19 @@ const createUpdater = ({ const superseded = manualOpen; const attempt = (async () => { + // 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; 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 }; + } } - const generationAtStart = actionGeneration; try { await openExternal(url); // Nothing was installed; the update stays available until the user From 39da78e4160c3c79b3163ba9501460169c3a4fb9 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 22:26:59 +0200 Subject: [PATCH 84/89] test(ui): expect a text-only Update pill while an update is available --- core/__tests__/UpdatePill.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/core/__tests__/UpdatePill.test.tsx b/core/__tests__/UpdatePill.test.tsx index 1f0680d..c99cf16 100644 --- a/core/__tests__/UpdatePill.test.tsx +++ b/core/__tests__/UpdatePill.test.tsx @@ -127,6 +127,15 @@ test('styles the pill like the shared buttons', async () => { expect(pill(view)?.className).toContain('codiff-button'); }); +test('shows the Update label without an icon', async () => { + await using view = await renderPill( + , + ); + + expect(pill(view)?.textContent).toBe('Update'); + expect(pill(view)?.querySelector('svg')).toBeNull(); +}); + test('applies the update with a single click', async () => { let applied = 0; await using view = await renderPill( From 275b834d97d941341574524e9e3b44047ac795c5 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 22:27:26 +0200 Subject: [PATCH 85/89] fix(ui): drop the sparkle icon from the update pill --- core/app/components/Panels.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/core/app/components/Panels.tsx b/core/app/components/Panels.tsx index ec6fbf5..38dad1b 100644 --- a/core/app/components/Panels.tsx +++ b/core/app/components/Panels.tsx @@ -7,7 +7,6 @@ import { CheckCircleIcon as CheckCircle } from '@phosphor-icons/react/CheckCircl 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 { SparkleIcon as Sparkle } from '@phosphor-icons/react/Sparkle'; import { WarningOctagonIcon as WarningOctagon } from '@phosphor-icons/react/WarningOctagon'; import { XIcon as X } from '@phosphor-icons/react/X'; import { Copy as LucideCopy } from 'lucide-react'; @@ -134,9 +133,7 @@ export function UpdatePill({ ) : phase === 'error' ? ( - ) : ( - - )} + ) : null} {phase === 'available' ? 'Update' From 433df2f60d95d387714e2b517736ce1d56991f08 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 22:30:02 +0200 Subject: [PATCH 86/89] test(updater): expect fresh requests not to share an invalidated hand-off --- electron/__tests__/updater.test.ts | 63 ++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/electron/__tests__/updater.test.ts b/electron/__tests__/updater.test.ts index d239a7f..b00ca47 100644 --- a/electron/__tests__/updater.test.ts +++ b/electron/__tests__/updater.test.ts @@ -882,6 +882,69 @@ test('a dismissal during a queued manual hand-off wins', async () => { 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, { From 3a1143ed93ce3d58b4febd79444218fea2002c8d Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 22:30:23 +0200 Subject: [PATCH 87/89] fix(updater): key shared manual hand-offs to their action generation --- electron/updater.cjs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/electron/updater.cjs b/electron/updater.cjs index fce98f9..161f09e 100644 --- a/electron/updater.cjs +++ b/electron/updater.cjs @@ -361,7 +361,7 @@ const createUpdater = ({ // 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 {{ promise: Promise; url: string } | null} */ + /** @type {{ generation: number; promise: Promise; url: string } | null} */ let manualOpen = null; const manualUpdateUrl = () => @@ -377,11 +377,11 @@ const createUpdater = ({ } 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 () => { - // 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; if (superseded) { // Let the older hand-off settle first; its completion yields to this // newer action through the generation check below. @@ -408,7 +408,7 @@ const createUpdater = ({ } })(); - manualOpen = { promise: attempt, url }; + manualOpen = { generation: generationAtStart, promise: attempt, url }; try { return await attempt; } finally { @@ -424,8 +424,16 @@ const createUpdater = ({ } // 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. - if (strategy === 'manual' && manualOpen?.url === manualUpdateUrl()) { + // 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) }; } From 231853c1efe920a4c79a220107b53ed1fb1fa4d3 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 22:32:09 +0200 Subject: [PATCH 88/89] test(ui): drop the icon-free pill assertion --- core/__tests__/UpdatePill.test.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/core/__tests__/UpdatePill.test.tsx b/core/__tests__/UpdatePill.test.tsx index c99cf16..1f0680d 100644 --- a/core/__tests__/UpdatePill.test.tsx +++ b/core/__tests__/UpdatePill.test.tsx @@ -127,15 +127,6 @@ test('styles the pill like the shared buttons', async () => { expect(pill(view)?.className).toContain('codiff-button'); }); -test('shows the Update label without an icon', async () => { - await using view = await renderPill( - , - ); - - expect(pill(view)?.textContent).toBe('Update'); - expect(pill(view)?.querySelector('svg')).toBeNull(); -}); - test('applies the update with a single click', async () => { let applied = 0; await using view = await renderPill( From 42addcf0203e23bce17d52814fdb866e667ad2dd Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 23:01:25 +0200 Subject: [PATCH 89/89] test(ui): drop the stylesheet-placement assertion --- core/__tests__/UpdatePill.test.tsx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/core/__tests__/UpdatePill.test.tsx b/core/__tests__/UpdatePill.test.tsx index 1f0680d..68e8342 100644 --- a/core/__tests__/UpdatePill.test.tsx +++ b/core/__tests__/UpdatePill.test.tsx @@ -1,7 +1,5 @@ // @vitest-environment jsdom -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { expect, test } from 'vite-plus/test'; @@ -107,18 +105,6 @@ test('promises nothing beyond the download without a known strategy', async () = expect(pill(view)?.title).not.toContain('opens'); }); -test('keeps the update pill styles out of the shared web stylesheet', () => { - // App.css ships to the web app; the update pill is desktop-only chrome and - // belongs in Desktop.css, which only the Electron entry imports. - const appCss = readFileSync(resolve('core/App.css'), 'utf8'); - const desktopCss = readFileSync(resolve('core/Desktop.css'), 'utf8'); - - expect(appCss).not.toContain('update-pill'); - expect(appCss).not.toContain('--update-accent'); - expect(desktopCss).toContain('.update-pill'); - expect(desktopCss).toContain('--update-accent'); -}); - test('styles the pill like the shared buttons', async () => { await using view = await renderPill( ,