From 28ad9db7f564bcd9618490ae81a1e5293acf9b59 Mon Sep 17 00:00:00 2001 From: Ron Borysowski Date: Thu, 7 May 2026 20:35:19 +0300 Subject: [PATCH] feat: auto-update UX for Windows, Linux AppImage, and macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `electron-updater` was already wired up at startup but the renderer never listened, leaving users with no signal that an update was queued, no manual check entry, and no way to apply the update on demand. This fills in the missing pieces and adds a macOS-safe fallback for unsigned builds. ## What ships - Bottom-right banner appears when an update is ready: * Windows + Linux AppImage: "Restart and install" applies the downloaded build via Squirrel; "Later" hides until next launch. * macOS + Linux .deb: "Open download page" jumps to the GitHub release; "Skip this version" persists per-version so users aren't nagged on every launch. - New "About" tab in Settings: app version, platform, last-checked timestamp, status line, "Check for updates" button, plus "Restart and install" / "Open download page" depending on mode. - Periodic re-check every 6 hours after the launch check (skipped if state is already in flight or terminal-pending). - electron-updater logs piped into electron-log (rotating file) so failures users report can be diagnosed after the fact. ## Architecture - `src/preload/updateTypes.ts` (new) — `UpdateState`, `UpdateStatus`, `InstallMode`. Single shared type used by main, preload, and renderer. - `src/main/services/updateState.ts` (new) — pure reducer with forward-only guards: `available` / `download-progress` / `downloaded` ignore late or duplicate events that would otherwise regress a downloaded or installing state. Capability flags (`canCheck`, `canInstall`, `canOpenDownload`) are derived deterministically so the renderer never re-derives platform rules. - `src/main/services/autoUpdater.ts` (rewritten) — wraps `electron-updater`, dispatches reducer actions, broadcasts state via `app:updateStateChanged`, exposes `getUpdateState`/`checkForUpdates`/`installUpdate`/`openUpdateDownload`. Platform routing: darwin → manualDownload, win32 → autoInstall, linux → autoInstall iff `process.env.APPIMAGE`, else manualDownload. Release URLs are constructed in main from a constant base — never accepted from the renderer. - `src/preload/index.ts` — flat `app.*` methods + a typed `app.onUpdateStateChanged` subscription that returns its own unsubscribe. - `src/renderer/src/stores/updateStore.ts` (new) — Zustand store. Subscribes to push events FIRST then primes from `getUpdateState`, and only seeds when state is still null so a slow snapshot reply can't clobber a newer pushed event during App mount. - `src/renderer/src/components/common/UpdateBanner.tsx` (new) — per-launch dismiss + persisted `skippedUpdateVersion` setting. - `src/renderer/src/components/settings/SettingsModal.tsx` — new About tab + AboutPanel. ## macOS workaround rationale Squirrel.Mac validates that the running bundle's code-signature certificate matches the replacement's. Ad-hoc signed builds use ephemeral identifiers, so the swap is rejected. Until Developer ID signing + notarization land, macOS sets both `autoDownload` and `autoInstallOnAppQuit` to false and the renderer renders the "Open download page" path. Removing the macOS branch is a one-line change once signing is in place. ## Tests - 16 reducer transitions covered in `tests/unit/update-state.test.ts`, including the forward-only guards added in response to the dual code review. - Existing 10 e2e tests pass against the new build. ## Code review Two parallel code reviews (Opus, GPT) found two issues, both fixed: - Reducer didn't guard `available` / `download-progress` / `downloaded` from regressing terminal-pending states. Added forward-only guards + tests. - Renderer `init()` could clobber a pushed state with a slow `getUpdateState()` reply. Now subscribes first and seeds only when state is still null. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 17 +- package-lock.json | 14 +- package.json | 3 +- src/main/index.ts | 18 +- src/main/services/autoUpdater.ts | 205 +++++++++++++--- src/main/services/updateState.ts | 214 +++++++++++++++++ src/preload/index.ts | 23 +- src/preload/updateTypes.ts | 47 ++++ src/renderer/src/App.tsx | 7 +- .../src/components/common/UpdateBanner.tsx | 142 ++++++++++++ .../src/components/settings/SettingsModal.tsx | 158 ++++++++++++- src/renderer/src/stores/settingsStore.ts | 3 +- src/renderer/src/stores/updateStore.ts | 95 ++++++++ src/renderer/src/types/index.ts | 7 + tests/unit/update-state.test.ts | 219 ++++++++++++++++++ tsconfig.web.json | 3 +- 16 files changed, 1129 insertions(+), 46 deletions(-) create mode 100644 src/main/services/updateState.ts create mode 100644 src/preload/updateTypes.ts create mode 100644 src/renderer/src/components/common/UpdateBanner.tsx create mode 100644 src/renderer/src/stores/updateStore.ts create mode 100644 tests/unit/update-state.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 547d0d3..983ef56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.0] — 2026-05-07 + +### Features + +- Auto-update: DPlex now checks for new releases on launch and every + 6 hours. On Windows and Linux AppImage, updates download silently + in the background and a banner offers a one-click "Restart and + install". On macOS and Linux `.deb` builds (where in-place + replacement isn't safe yet) the banner instead links to the + release page; you can also "Skip this version" to stop being + prompted for that release. +- New "About" tab in Settings shows the current version, last update + check time, and a manual "Check for updates" button. + ## [0.10.0] — 2026-05-07 ### Features @@ -328,7 +342,8 @@ AI-assisted development. - Eight built-in themes (dark and light variants). - Keyboard shortcuts for tabs, splits, sidebar, and settings. -[Unreleased]: https://github.com/Ron537/DPlex/compare/v0.10.0...HEAD +[Unreleased]: https://github.com/Ron537/DPlex/compare/v0.11.0...HEAD +[0.11.0]: https://github.com/Ron537/DPlex/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/Ron537/DPlex/compare/v0.9.2...v0.10.0 [0.9.2]: https://github.com/Ron537/DPlex/compare/v0.9.1...v0.9.2 [0.9.1]: https://github.com/Ron537/DPlex/compare/v0.9.0...v0.9.1 diff --git a/package-lock.json b/package-lock.json index eafed16..fdeabf3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dplex", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dplex", - "version": "0.9.0", + "version": "0.10.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -19,6 +19,7 @@ "@xterm/xterm": "^6.0.0", "allotment": "^1.20.5", "diff": "^9.0.0", + "electron-log": "^5.4.3", "electron-updater": "^6.8.3", "ignore": "^5.3.2", "lucide-react": "^1.7.0", @@ -5131,6 +5132,15 @@ "node": ">= 10.0.0" } }, + "node_modules/electron-log": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.3.tgz", + "integrity": "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/electron-publish": { "version": "26.8.1", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz", diff --git a/package.json b/package.json index af8efa4..46ee26d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dplex", - "version": "0.10.0", + "version": "0.11.0", "description": "A terminal multiplexer built for AI-assisted development. Manage multiple AI CLI sessions (Copilot, Claude, and more) alongside regular terminals in one window.", "main": "./out/main/index.js", "author": { @@ -63,6 +63,7 @@ "@xterm/xterm": "^6.0.0", "allotment": "^1.20.5", "diff": "^9.0.0", + "electron-log": "^5.4.3", "electron-updater": "^6.8.3", "ignore": "^5.3.2", "lucide-react": "^1.7.0", diff --git a/src/main/index.ts b/src/main/index.ts index a9bc29b..44ceda7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -32,7 +32,13 @@ import { setActiveCompositeId } from './services/notifications' import * as attentionService from './services/attentionService' -import { initAutoUpdater } from './services/autoUpdater' +import { + initAutoUpdater, + getUpdateState, + checkForUpdates, + installUpdate, + openUpdateDownload +} from './services/autoUpdater' import { makeCompositeId } from '../preload/attentionTypes' import { getBranch, @@ -494,6 +500,16 @@ function registerIpcHandlers(): void { return getBranch(dirPath) }) + // App version + auto-update + ipcMain.handle('app:getVersion', () => app.getVersion()) + ipcMain.handle('app:getUpdateState', () => getUpdateState()) + ipcMain.handle('app:checkForUpdates', () => checkForUpdates()) + ipcMain.handle('app:installUpdate', () => installUpdate()) + ipcMain.handle('app:openUpdateDownload', () => { + openUpdateDownload() + return getUpdateState() + }) + // Git service — reactive branch watching ipcMain.handle('git:getBranch', async (_event, dirPath: string) => { return getBranch(dirPath) diff --git a/src/main/services/autoUpdater.ts b/src/main/services/autoUpdater.ts index 53d7bf9..5feba0c 100644 --- a/src/main/services/autoUpdater.ts +++ b/src/main/services/autoUpdater.ts @@ -1,60 +1,197 @@ -import { app, BrowserWindow } from 'electron' +import { app, BrowserWindow, shell } from 'electron' import { autoUpdater } from 'electron-updater' +import log from 'electron-log/main' +import type { InstallMode, UpdateState } from '../../preload/updateTypes' +import { + initialState, + reduce, + shouldSkipPeriodicCheck, + type UpdateAction +} from './updateState' + +const RELEASE_URL_BASE = 'https://github.com/Ron537/DPlex/releases' +const PERIODIC_CHECK_MS = 6 * 60 * 60 * 1000 /** - * Wires automatic-update checks for packaged builds. + * Coordinates auto-update for packaged builds. * - * electron-updater reads its feed location from `publish:` in - * `electron-builder.yml` at build time. For DPlex that's GitHub Releases - * — a signed release tagged `v*` appears in the users' app within a few - * hours of publishing. + * Per-platform behaviour: * - * Skipped entirely in development and unpackaged builds: there's no - * meaningful update feed and the updater logs noisy errors otherwise. + * - **Windows + Linux AppImage** (`autoInstall`): silent background + * download, banner offered when ready, "Restart and install" applies + * the update via Squirrel. + * - **macOS** (`manualDownload`): notification only. We can't apply an + * update in place because Squirrel.Mac validates that the running + * bundle's code-signature certificate matches the replacement and + * ad-hoc signed builds use ephemeral identifiers, so the swap is + * rejected. The banner offers a "Download" button that opens the + * GitHub releases page; the user replaces the .app manually. Once we + * have Developer ID signing + notarization, this branch goes away. + * - **Linux non-AppImage** (`manualDownload`): same as macOS. We don't + * want to drive `dpkg`/`apt` from the app — the user knows their + * package manager and can grab the new `.deb` from the release page. + * - **Unpackaged** (`unsupported`): no-op everywhere. * - * This is a minimal hook: it logs progress, surfaces failures quietly, - * and installs downloaded updates on next quit. It does not yet display - * an in-app banner or let the user defer an update — those are UI - * additions that can come later. + * The renderer never sees the underlying `electron-updater` instance — + * only `UpdateState` snapshots over IPC and a small command surface + * (`check`, `install`, `openDownload`). */ -export function initAutoUpdater(getMainWindow: () => BrowserWindow | null): void { - if (!app.isPackaged) return - autoUpdater.autoDownload = true - autoUpdater.autoInstallOnAppQuit = true +let getMainWindow: (() => BrowserWindow | null) | null = null +let state: UpdateState = initialState('unsupported') +let periodicTimer: NodeJS.Timeout | null = null +let started = false + +export function initAutoUpdater(getMainWindowFn: () => BrowserWindow | null): void { + if (started) return + started = true + getMainWindow = getMainWindowFn + state = initialState(resolveInstallMode()) + + if (!app.isPackaged || state.installMode === 'unsupported') { + return + } + + // Pipe electron-updater's verbose logging into a rotating file so + // failures the user reports can be diagnosed after the fact. + log.transports.file.level = 'info' + autoUpdater.logger = log + + autoUpdater.autoDownload = state.installMode === 'autoInstall' + autoUpdater.autoInstallOnAppQuit = state.installMode === 'autoInstall' // Allow prerelease channels only if the current version is itself a // prerelease (semver tag contains a hyphen) — keeps stable users on - // stable builds while letting early testers on `v0.2.0-beta.1` track + // stable builds while letting early testers on `v0.x.0-beta.1` track // the beta track. autoUpdater.allowPrerelease = /-/.test(app.getVersion()) autoUpdater.on('error', (err) => { - // Never throw into the main event loop — a missing feed or offline - // user must not crash the app. Log for debugging only. - console.warn('[auto-update] error:', err?.message ?? err) + log.warn('[auto-update] error:', err) + dispatch({ + type: 'error', + message: shortenError(err) + }) }) - autoUpdater.on('update-available', (info) => { - console.log('[auto-update] update available:', info.version) + autoUpdater.on('checking-for-update', () => { + dispatch({ type: 'check-started' }) }) autoUpdater.on('update-not-available', () => { - console.log('[auto-update] already up to date') + dispatch({ type: 'check-finished-no-update' }) + }) + + autoUpdater.on('update-available', (info) => { + dispatch({ + type: 'available', + version: info.version, + releaseUrl: `${RELEASE_URL_BASE}/tag/v${info.version}` + }) + }) + + autoUpdater.on('download-progress', (info) => { + dispatch({ + type: 'download-progress', + percent: info.percent, + version: state.version + }) }) autoUpdater.on('update-downloaded', (info) => { - console.log('[auto-update] update downloaded:', info.version) - // Let the renderer surface a toast/banner when we add one. The update - // installs automatically on next quit, so no forced restart. - const win = getMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('app:updateDownloaded', { version: info.version }) - } + dispatch({ type: 'downloaded', version: info.version }) }) - // Fire-and-forget check. electron-updater debounces internally and - // handles retries, so we don't schedule our own polling. - autoUpdater.checkForUpdates().catch((err) => { - console.warn('[auto-update] initial check failed:', err?.message ?? err) + // Initial check + recurring poll. `electron-updater` debounces + // overlapping requests internally; we still skip when state is in a + // terminal-pending status to avoid noisy regressions. + void runCheck('startup') + periodicTimer = setInterval(() => { + if (shouldSkipPeriodicCheck(state.status)) return + void runCheck('periodic') + }, PERIODIC_CHECK_MS) +} + +export function shutdownAutoUpdater(): void { + if (periodicTimer) { + clearInterval(periodicTimer) + periodicTimer = null + } + started = false +} + +export function getUpdateState(): UpdateState { + return state +} + +export async function checkForUpdates(): Promise { + if (!state.canCheck) return state + await runCheck('manual') + return state +} + +export function installUpdate(): UpdateState { + if (!state.canInstall) return state + dispatch({ type: 'install-started' }) + // `quitAndInstall` synchronously fires `before-quit` and then exits. + // Wrap in setImmediate so the renderer round-trips the state update + // (showing the disabled "Restarting…" button) before the process + // tears down. + setImmediate(() => { + try { + autoUpdater.quitAndInstall(false, true) + } catch (err) { + log.error('[auto-update] quitAndInstall failed:', err) + dispatch({ type: 'error', message: shortenError(err) }) + } }) + return state +} + +export function openUpdateDownload(): void { + if (!state.canOpenDownload) return + const url = state.releaseUrl ?? `${RELEASE_URL_BASE}/latest` + void shell.openExternal(url) +} + +function dispatch(action: UpdateAction): void { + const next = reduce(state, action, { installMode: state.installMode }) + if (next === state) return + state = next + const win = getMainWindow?.() + if (win && !win.isDestroyed()) { + win.webContents.send('app:updateStateChanged', state) + } +} + +async function runCheck(reason: 'startup' | 'periodic' | 'manual'): Promise { + try { + log.info(`[auto-update] check (${reason})`) + await autoUpdater.checkForUpdates() + } catch (err) { + log.warn(`[auto-update] check (${reason}) failed:`, err) + dispatch({ type: 'error', message: shortenError(err) }) + } +} + +function resolveInstallMode(): InstallMode { + if (!app.isPackaged) return 'unsupported' + if (process.platform === 'darwin') return 'manualDownload' + if (process.platform === 'win32') return 'autoInstall' + if (process.platform === 'linux') { + // electron-builder sets APPIMAGE to the .AppImage path when running + // from one. Anything else (.deb, .rpm, source build) gets the + // manual path so we don't try to invoke a privileged package + // manager from inside the app. + return process.env.APPIMAGE ? 'autoInstall' : 'manualDownload' + } + return 'unsupported' +} + +function shortenError(err: unknown): string { + if (err instanceof Error) { + // First line is usually the most useful summary (`HttpError: + // 404 not found`, `net::ERR_INTERNET_DISCONNECTED`, etc.). + return err.message.split('\n')[0].slice(0, 160) + } + return String(err).slice(0, 160) } diff --git a/src/main/services/updateState.ts b/src/main/services/updateState.ts new file mode 100644 index 0000000..505d49a --- /dev/null +++ b/src/main/services/updateState.ts @@ -0,0 +1,214 @@ +import type { InstallMode, UpdateState, UpdateStatus } from '../../preload/updateTypes' + +/** + * Pure reducer for the auto-update state machine. + * + * Kept side-effect free so it can be unit-tested without spinning up + * Electron or `electron-updater`. The owning service layer + * (`autoUpdater.ts`) translates updater events into the action shapes + * defined here and re-derives capability flags after every transition. + * + * Capability fields are recomputed deterministically from `status` + + * `installMode` so the renderer never has to encode platform rules of + * its own — it just renders whichever buttons are flagged available. + */ + +export type UpdateAction = + | { type: 'check-started' } + | { type: 'check-finished-no-update' } + | { type: 'available'; version: string; releaseUrl?: string } + | { type: 'download-progress'; percent: number; version?: string } + | { type: 'downloaded'; version: string } + | { type: 'install-started' } + | { type: 'error'; message: string } + +export interface ReducerOptions { + installMode: InstallMode + /** Optional clock so tests can pin `lastChecked` deterministically. */ + now?: () => number +} + +export function initialState(installMode: InstallMode): UpdateState { + return withCapabilities({ + status: installMode === 'unsupported' ? 'unsupported' : 'idle', + installMode, + canCheck: false, + canInstall: false, + canOpenDownload: false + }) +} + +export function reduce( + state: UpdateState, + action: UpdateAction, + opts: ReducerOptions +): UpdateState { + const now = opts.now ?? Date.now + // Unsupported builds (dev, .deb when we treat it as "use your package + // manager", etc.) ignore every transition — the user just sees the + // "unsupported" badge. + if (state.installMode === 'unsupported' && action.type !== 'error') { + return state + } + + switch (action.type) { + case 'check-started': + // Don't regress from a useful terminal state into "checking" — + // periodic re-checks shouldn't blow away a downloaded update. + if ( + state.status === 'downloading' || + state.status === 'downloaded' || + state.status === 'installing' + ) { + return state + } + return withCapabilities({ + ...state, + status: 'checking', + error: undefined + }) + + case 'check-finished-no-update': + // Same guard — a terminal state must be sticky. + if ( + state.status === 'downloading' || + state.status === 'downloaded' || + state.status === 'installing' + ) { + return state + } + return withCapabilities({ + ...state, + status: 'up-to-date', + version: undefined, + downloadProgress: undefined, + error: undefined, + lastChecked: now() + }) + + case 'available': { + // Forward-only: don't let a late `update-available` (which + // electron-updater may resend as the next periodic check rolls + // around) regress a download already in flight or a downloaded + // bundle waiting on `quitAndInstall`. Bumping the offered + // version is fine — that's a real new release — but the same + // version arriving again must be a no-op. + if (state.installMode === 'autoInstall') { + if (state.status === 'installing') return state + if ( + (state.status === 'downloading' || state.status === 'downloaded') && + state.version === action.version + ) { + return state + } + } + // For autoInstall platforms, electron-updater immediately moves + // into download; we'll render `available` for the brief gap until + // the first progress event. For manualDownload (macOS, .deb) we + // sit on `available` and surface a download URL. + const next: UpdateState = { + ...state, + status: 'available', + version: action.version, + downloadProgress: undefined, + error: undefined, + lastChecked: now(), + releaseUrl: action.releaseUrl ?? state.releaseUrl + } + return withCapabilities(next) + } + + case 'download-progress': + // Already downloaded or about to install? A late progress event + // must not undo that. + if (state.status === 'downloaded' || state.status === 'installing') { + return state + } + return withCapabilities({ + ...state, + status: 'downloading', + version: action.version ?? state.version, + downloadProgress: clampPercent(action.percent), + error: undefined + }) + + case 'downloaded': + // Already restarting → don't pull the rug out from the + // installing flow. + if (state.status === 'installing') return state + return withCapabilities({ + ...state, + status: 'downloaded', + version: action.version, + downloadProgress: 100, + error: undefined, + lastChecked: now() + }) + + case 'install-started': + // Defensive: only valid from `downloaded` and only when the + // platform allows auto-install. + if (state.status !== 'downloaded' || state.installMode !== 'autoInstall') { + return state + } + return withCapabilities({ + ...state, + status: 'installing', + error: undefined + }) + + case 'error': + // An error doesn't undo a downloaded update — keep the file on + // disk so a retry/restart still works. + if (state.status === 'downloaded' || state.status === 'installing') { + return withCapabilities({ ...state, error: action.message }) + } + return withCapabilities({ + ...state, + status: 'error', + downloadProgress: undefined, + error: action.message, + lastChecked: now() + }) + } +} + +function withCapabilities(state: UpdateState): UpdateState { + const isUnsupported = state.installMode === 'unsupported' + const inflight = state.status === 'checking' || state.status === 'downloading' + + return { + ...state, + canCheck: !isUnsupported && !inflight && state.status !== 'installing', + canInstall: + !isUnsupported && + state.installMode === 'autoInstall' && + state.status === 'downloaded', + canOpenDownload: + !isUnsupported && + state.installMode === 'manualDownload' && + (state.status === 'available' || state.status === 'downloaded') + } +} + +function clampPercent(p: number): number { + if (!Number.isFinite(p)) return 0 + if (p < 0) return 0 + if (p > 100) return 100 + return Math.round(p) +} + +/** + * True iff a periodic check should be skipped because the state is + * either already in flight or terminal-pending. Used by the periodic + * timer to avoid noisy regressions. + */ +export function shouldSkipPeriodicCheck(status: UpdateStatus): boolean { + return ( + status === 'checking' || + status === 'downloading' || + status === 'downloaded' || + status === 'installing' || + status === 'unsupported' + ) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index eb6a240..a65e41c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import type { AttentionSnapshot } from './attentionTypes' +import type { UpdateState } from './updateTypes' import type { CreateWorktreeOptions, CreateWorktreeResult, @@ -138,6 +139,12 @@ export interface DplexAPI { getAvailableShells: () => Promise<{ name: string; path: string }[]> selectFolder: () => Promise getGitBranch: (dirPath: string) => Promise + getVersion: () => Promise + getUpdateState: () => Promise + checkForUpdates: () => Promise + installUpdate: () => Promise + openUpdateDownload: () => Promise + onUpdateStateChanged: (cb: (state: UpdateState) => void) => () => void } git: { getBranch: (dirPath: string) => Promise @@ -285,7 +292,21 @@ const dplexAPI: DplexAPI = { getHomedir: () => ipcRenderer.invoke('app:getHomedir'), getAvailableShells: () => ipcRenderer.invoke('app:getAvailableShells'), selectFolder: () => ipcRenderer.invoke('app:selectFolder'), - getGitBranch: (dirPath: string) => ipcRenderer.invoke('app:getGitBranch', dirPath) + getGitBranch: (dirPath: string) => ipcRenderer.invoke('app:getGitBranch', dirPath), + getVersion: () => ipcRenderer.invoke('app:getVersion'), + getUpdateState: () => ipcRenderer.invoke('app:getUpdateState'), + checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'), + installUpdate: () => ipcRenderer.invoke('app:installUpdate'), + openUpdateDownload: () => ipcRenderer.invoke('app:openUpdateDownload'), + onUpdateStateChanged: (callback: (state: UpdateState) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, state: UpdateState): void => { + callback(state) + } + ipcRenderer.on('app:updateStateChanged', handler) + return () => { + ipcRenderer.removeListener('app:updateStateChanged', handler) + } + } }, git: { getBranch: (dirPath: string) => ipcRenderer.invoke('git:getBranch', dirPath), diff --git a/src/preload/updateTypes.ts b/src/preload/updateTypes.ts new file mode 100644 index 0000000..c47ff45 --- /dev/null +++ b/src/preload/updateTypes.ts @@ -0,0 +1,47 @@ +/** + * Auto-update state shared between main, preload, and renderer. + * + * This file lives under `src/preload/` (rather than a renderer-only types + * directory) so the main process can import it without the renderer + * tsconfig having to reach into renderer-private paths. The existing + * `attentionTypes.ts` follows the same pattern. + * + * The main process owns the state machine and pushes the latest + * `UpdateState` to the renderer over IPC; both sides import this module + * so the contract stays in one place. + */ + +export type UpdateStatus = + | 'idle' + | 'checking' + | 'up-to-date' + | 'available' + | 'downloading' + | 'downloaded' + | 'installing' + | 'error' + | 'unsupported' + +export type InstallMode = 'autoInstall' | 'manualDownload' | 'unsupported' + +export interface UpdateState { + status: UpdateStatus + /** Newer version offered by the feed (if any). */ + version?: string + /** Download progress 0-100 while status === 'downloading'. */ + downloadProgress?: number + /** Short user-facing error message (full detail goes to electron-log). */ + error?: string + /** Epoch ms of the last completed check (success or failure). */ + lastChecked?: number + /** GitHub release URL for `manualDownload` flows. Always set in main. */ + releaseUrl?: string + /** How an update will land on this platform/package format. */ + installMode: InstallMode + /** Whether the user can trigger a manual check right now. */ + canCheck: boolean + /** Whether "Restart and install" is meaningful right now. */ + canInstall: boolean + /** Whether "Open download page" is meaningful right now. */ + canOpenDownload: boolean +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e302fc4..db41380 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,13 +1,16 @@ import { useEffect, useCallback } from 'react' import { AppLayout } from './components/layout/AppLayout' import { ProviderIconSprite } from './components/common/ProviderIconSprite' +import { UpdateBanner } from './components/common/UpdateBanner' import { useSettingsStore } from './stores/settingsStore' import { useTerminalStore } from './stores/terminalStore' import { useProvidersStore } from './stores/providersStore' +import { useUpdateStore } from './stores/updateStore' function App(): React.JSX.Element { const loadSettings = useSettingsStore((s) => s.loadSettings) const loadProviders = useProvidersStore((s) => s.load) + const initUpdateStore = useUpdateStore((s) => s.init) const toggleSidebar = useSettingsStore((s) => s.toggleSidebar) const createTerminal = useTerminalStore((s) => s.createTerminal) const closeTerminal = useTerminalStore((s) => s.closeTerminal) @@ -18,7 +21,8 @@ function App(): React.JSX.Element { useEffect(() => { loadSettings() loadProviders() - }, [loadSettings, loadProviders]) + return initUpdateStore() + }, [loadSettings, loadProviders, initUpdateStore]) const handleKeyDown = useCallback( (e: KeyboardEvent) => { @@ -107,6 +111,7 @@ function App(): React.JSX.Element { <> + ) } diff --git a/src/renderer/src/components/common/UpdateBanner.tsx b/src/renderer/src/components/common/UpdateBanner.tsx new file mode 100644 index 0000000..2058b84 --- /dev/null +++ b/src/renderer/src/components/common/UpdateBanner.tsx @@ -0,0 +1,142 @@ +import { useMemo, type JSX } from 'react' +import { Download, RefreshCcw, X } from 'lucide-react' +import { useUpdateStore } from '../../stores/updateStore' +import { useSettingsStore } from '../../stores/settingsStore' + +/** + * Slim toast surfaced at the bottom-right when an update is ready for + * the user's attention. + * + * Two flavors driven by the main process's `installMode`: + * + * - `autoInstall` (Windows / Linux AppImage) — appears on `downloaded` + * with a "Restart and install" primary action. + * - `manualDownload` (macOS / .deb) — appears on `available` with an + * "Open download page" primary action and a secondary "Skip this + * version" so the user isn't nagged on every launch. + * + * The banner respects two suppression mechanisms: + * - per-launch dismiss (auto-install case): clicking "Later" hides it + * until the next time the app starts. + * - persisted skip-version (manual case): the version string is + * written into `settings.skippedUpdateVersion` and the banner stays + * hidden until a newer version appears. + */ +export function UpdateBanner(): JSX.Element | null { + const state = useUpdateStore((s) => s.state) + const dismissed = useUpdateStore((s) => s.dismissed) + const dismiss = useUpdateStore((s) => s.dismiss) + const install = useUpdateStore((s) => s.install) + const openDownload = useUpdateStore((s) => s.openDownload) + const skippedVersion = useSettingsStore((s) => s.settings.skippedUpdateVersion) + const updateSettings = useSettingsStore((s) => s.updateSettings) + + const visible = useMemo(() => { + if (!state || !state.version) return false + if (state.installMode === 'autoInstall') { + return state.status === 'downloaded' && !dismissed + } + if (state.installMode === 'manualDownload') { + if (state.status !== 'available' && state.status !== 'downloaded') return false + if (skippedVersion === state.version) return false + return !dismissed + } + return false + }, [state, dismissed, skippedVersion]) + + if (!state || !visible) return null + + const isAuto = state.installMode === 'autoInstall' + const handlePrimary = (): void => { + void (isAuto ? install() : openDownload()) + } + const handleSkip = (): void => { + if (state.version) { + void updateSettings({ skippedUpdateVersion: state.version }) + } + } + + return ( +
+
+ +
+
+ DPlex v{state.version} {isAuto ? 'is ready to install' : 'is available'} +
+
+ {isAuto + ? 'Restart now to apply the update, or close the app any time later.' + : 'Download the new build from the GitHub release page and replace the current app.'} +
+
+ +
+ +
+ + {!isAuto && ( + + )} + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/SettingsModal.tsx b/src/renderer/src/components/settings/SettingsModal.tsx index 6136463..863cb5a 100644 --- a/src/renderer/src/components/settings/SettingsModal.tsx +++ b/src/renderer/src/components/settings/SettingsModal.tsx @@ -1,11 +1,13 @@ import { useState, useEffect, useRef } from 'react' -import { X, Palette, Terminal, Bot, Keyboard, BellRing, GitBranch } from 'lucide-react' +import { X, Palette, Terminal, Bot, Keyboard, BellRing, GitBranch, Info } from 'lucide-react' import { useSettingsStore } from '../../stores/settingsStore' import { useSessionStore } from '../../stores/sessionStore' +import { useUpdateStore } from '../../stores/updateStore' import { getThemesByVariant, getTheme } from '../../services/themes' import { applyThemeToAll } from '../../services/terminalRegistry' import type { ShellInfo, AppSettings } from '../../types' import { MOD, SHIFT } from '../../utils/shortcuts' +import { timeAgo } from '../../utils/timeAgo' interface SettingsModalProps { isOpen: boolean @@ -19,6 +21,7 @@ type SettingsTab = | 'notifications' | 'worktrees' | 'shortcuts' + | 'about' const SHORTCUTS: { category: string; items: { keys: string; description: string }[] }[] = [ { @@ -51,7 +54,7 @@ const SHORTCUTS: { category: string; items: { keys: string; description: string type SettingsTabGroup = { title: string; tabs: SettingsTab[] } const TAB_GROUPS: SettingsTabGroup[] = [ - { title: 'General', tabs: ['appearance', 'shortcuts'] }, + { title: 'General', tabs: ['appearance', 'shortcuts', 'about'] }, { title: 'AI Tools', tabs: ['ai-tools', 'worktrees', 'notifications'] }, { title: 'Terminal', tabs: ['terminal'] } ] @@ -80,6 +83,10 @@ const TAB_HEADINGS: Record shortcuts: { title: 'Shortcuts', description: 'Keyboard shortcuts. These are not configurable yet.' + }, + about: { + title: 'About', + description: 'Version info and update controls.' } } @@ -89,7 +96,8 @@ const TABS: { id: SettingsTab; label: string; icon: typeof Palette }[] = [ { id: 'ai-tools', label: 'AI Tools', icon: Bot }, { id: 'notifications', label: 'Notifications', icon: BellRing }, { id: 'worktrees', label: 'Worktrees', icon: GitBranch }, - { id: 'shortcuts', label: 'Shortcuts', icon: Keyboard } + { id: 'shortcuts', label: 'Shortcuts', icon: Keyboard }, + { id: 'about', label: 'About', icon: Info } ] function SettingItem({ @@ -958,6 +966,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps): React.JS ))} )} + + {activeTab === 'about' && } {/* Footer — flush with the bottom of the right pane. */} @@ -1000,3 +1010,145 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps): React.JS ) } + +/** + * About tab — version info, platform, and update controls. Reads + * everything from the renderer-side `useUpdateStore` so the panel + * stays in sync with the global banner. + */ +function AboutPanel(): React.JSX.Element { + const state = useUpdateStore((s) => s.state) + const check = useUpdateStore((s) => s.check) + const install = useUpdateStore((s) => s.install) + const openDownload = useUpdateStore((s) => s.openDownload) + const [version, setVersion] = useState('') + const [platform, setPlatform] = useState('') + + useEffect(() => { + void window.dplex.app.getVersion().then(setVersion) + void window.dplex.app.getPlatform().then(setPlatform) + }, []) + + const status = state?.status ?? 'idle' + const statusLabel = (() => { + if (state?.installMode === 'unsupported') { + return 'Auto-update is unavailable in development builds.' + } + switch (status) { + case 'idle': + return 'Click below to check for updates.' + case 'checking': + return 'Checking for updates…' + case 'up-to-date': + return "You're on the latest version." + case 'available': + return state?.installMode === 'manualDownload' + ? `v${state.version} is available — download to install.` + : `v${state?.version} is available — downloading…` + case 'downloading': + return `Downloading v${state?.version}… ${state?.downloadProgress ?? 0}%` + case 'downloaded': + return `v${state?.version} is downloaded — restart to install.` + case 'installing': + return 'Restarting to install the update…' + case 'error': + return state?.error ? `Update check failed: ${state.error}` : 'Update check failed.' + case 'unsupported': + return 'Auto-update is unavailable for this build.' + } + })() + + return ( +
+
+
+ Version +
+
+ {version || '—'} + + ({platform || '—'}) + +
+
+ +
+
+
+ Updates +
+
+ {statusLabel} +
+ {state?.lastChecked && ( +
+ Last checked {timeAgo(state.lastChecked)} ago +
+ )} +
+ +
+ + {state?.canInstall && ( + + )} + {state?.canOpenDownload && ( + + )} +
+
+
+ ) +} diff --git a/src/renderer/src/stores/settingsStore.ts b/src/renderer/src/stores/settingsStore.ts index d2e1bd0..54b6998 100644 --- a/src/renderer/src/stores/settingsStore.ts +++ b/src/renderer/src/stores/settingsStore.ts @@ -153,7 +153,8 @@ const DEFAULT_SETTINGS: AppSettings = { open: false, width: 300, sectionCollapse: { changes: false } - } + }, + skippedUpdateVersion: null } interface SettingsState { diff --git a/src/renderer/src/stores/updateStore.ts b/src/renderer/src/stores/updateStore.ts new file mode 100644 index 0000000..7c38290 --- /dev/null +++ b/src/renderer/src/stores/updateStore.ts @@ -0,0 +1,95 @@ +import { create } from 'zustand' +import type { UpdateState } from '../../../preload/updateTypes' + +/** + * Renderer-side mirror of the main process's auto-update state. + * + * The store holds the most recent snapshot pushed via the + * `app:updateStateChanged` IPC event, plus a transient `dismissed` flag + * the banner uses for per-launch suppression. It also tracks the + * `manualDownload` "skipped version" pulled from settings so we don't + * nag macOS users every launch about a release they already declined. + * + * `init()` is idempotent and intended to be called once from `App.tsx` + * — it primes the store with the current state and subscribes to + * subsequent updates. The returned cleanup is wired into a `useEffect`. + */ + +interface UpdateStoreState { + state: UpdateState | null + /** Per-launch dismissal of the auto-install banner ("Later"). */ + dismissed: boolean + + init: () => () => void + check: () => Promise + install: () => Promise + openDownload: () => Promise + dismiss: () => void +} + +const PLACEHOLDER: UpdateState = { + status: 'idle', + installMode: 'unsupported', + canCheck: false, + canInstall: false, + canOpenDownload: false +} + +export const useUpdateStore = create((set, get) => { + let unsubscribe: (() => void) | null = null + + return { + state: null, + dismissed: false, + + init: () => { + if (unsubscribe) return unsubscribe + // Subscribe FIRST so any push that arrives while the initial + // `getUpdateState()` round-trip is in flight isn't lost. + const off = window.dplex.app.onUpdateStateChanged((next) => { + // Reset the per-launch dismissal whenever a *newer* version + // shows up — otherwise dismissing 0.10.1 would also dismiss + // the future 0.10.2 banner. + const current = get().state + if (current?.version && next.version && current.version !== next.version) { + set({ dismissed: false }) + } + set({ state: next }) + }) + // Then prime the store with whatever the main process currently + // has — but only if a push hasn't already populated us. Without + // this guard, a slow IPC reply could clobber a newer pushed + // state captured during the same render tick. + void window.dplex.app + .getUpdateState() + .then((s) => { + if (get().state == null) set({ state: s ?? PLACEHOLDER }) + }) + .catch(() => { + if (get().state == null) set({ state: PLACEHOLDER }) + }) + unsubscribe = () => { + off() + unsubscribe = null + } + return unsubscribe + }, + + check: async () => { + const next = await window.dplex.app.checkForUpdates() + set({ state: next }) + }, + + install: async () => { + const next = await window.dplex.app.installUpdate() + set({ state: next }) + }, + + openDownload: async () => { + const next = await window.dplex.app.openUpdateDownload() + set({ state: next }) + }, + + dismiss: () => set({ dismissed: true }) + } +}) diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index b716c79..7cd11cb 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -139,6 +139,13 @@ export interface AppSettings { projectPanelShowFooter: boolean /** Right-side Git panel UI state. */ gitPanel: GitPanelSettings + /** + * Version the user explicitly chose to skip from the update banner. + * Honored only for `manualDownload` flows (macOS, .deb) — auto-install + * platforms surface the banner only after the bytes are already on + * disk and a "Skip" choice there would just waste the download. + */ + skippedUpdateVersion: string | null } export interface GitPanelSettings { diff --git a/tests/unit/update-state.test.ts b/tests/unit/update-state.test.ts new file mode 100644 index 0000000..296413c --- /dev/null +++ b/tests/unit/update-state.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'vitest' +import { + initialState, + reduce, + shouldSkipPeriodicCheck +} from '../../src/main/services/updateState' +import type { InstallMode } from '../../src/preload/updateTypes' + +const FAKE_NOW = 1_700_000_000_000 +const opts = (mode: InstallMode): { installMode: InstallMode; now: () => number } => ({ + installMode: mode, + now: () => FAKE_NOW +}) + +describe('updateState reducer', () => { + it('initial state derives capabilities from install mode', () => { + const auto = initialState('autoInstall') + expect(auto.status).toBe('idle') + expect(auto.canCheck).toBe(true) + expect(auto.canInstall).toBe(false) + expect(auto.canOpenDownload).toBe(false) + + const manual = initialState('manualDownload') + expect(manual.canCheck).toBe(true) + + const unsupported = initialState('unsupported') + expect(unsupported.status).toBe('unsupported') + expect(unsupported.canCheck).toBe(false) + }) + + it('autoInstall: full happy path', () => { + let s = initialState('autoInstall') + s = reduce(s, { type: 'check-started' }, opts('autoInstall')) + expect(s.status).toBe('checking') + expect(s.canCheck).toBe(false) + + s = reduce(s, { type: 'available', version: '1.2.0' }, opts('autoInstall')) + expect(s.status).toBe('available') + expect(s.version).toBe('1.2.0') + expect(s.canInstall).toBe(false) + + s = reduce(s, { type: 'download-progress', percent: 42 }, opts('autoInstall')) + expect(s.status).toBe('downloading') + expect(s.downloadProgress).toBe(42) + + s = reduce(s, { type: 'downloaded', version: '1.2.0' }, opts('autoInstall')) + expect(s.status).toBe('downloaded') + expect(s.canInstall).toBe(true) + expect(s.canOpenDownload).toBe(false) + + s = reduce(s, { type: 'install-started' }, opts('autoInstall')) + expect(s.status).toBe('installing') + expect(s.canInstall).toBe(false) + expect(s.canCheck).toBe(false) + }) + + it('manualDownload: stays on `available` and surfaces openDownload', () => { + let s = initialState('manualDownload') + s = reduce( + s, + { type: 'available', version: '0.11.0', releaseUrl: 'https://example.test' }, + opts('manualDownload') + ) + expect(s.status).toBe('available') + expect(s.releaseUrl).toBe('https://example.test') + expect(s.canOpenDownload).toBe(true) + expect(s.canInstall).toBe(false) + }) + + it('install-started ignored unless status is downloaded + autoInstall', () => { + let s = initialState('autoInstall') + s = reduce(s, { type: 'available', version: '2.0.0' }, opts('autoInstall')) + const before = s + s = reduce(s, { type: 'install-started' }, opts('autoInstall')) + expect(s).toBe(before) + + let m = initialState('manualDownload') + m = reduce(m, { type: 'downloaded', version: '2.0.0' }, opts('manualDownload')) + const beforeM = m + m = reduce(m, { type: 'install-started' }, opts('manualDownload')) + expect(m).toBe(beforeM) + }) + + it('check-started does not regress from terminal-pending states', () => { + let s = initialState('autoInstall') + s = reduce(s, { type: 'downloaded', version: '3.0.0' }, opts('autoInstall')) + const beforeDl = s + + s = reduce(s, { type: 'check-started' }, opts('autoInstall')) + expect(s).toBe(beforeDl) + expect(s.status).toBe('downloaded') + + s = reduce(s, { type: 'check-finished-no-update' }, opts('autoInstall')) + expect(s.status).toBe('downloaded') + }) + + it('error sets status=error and clears progress for transient states', () => { + let s = initialState('autoInstall') + s = reduce(s, { type: 'check-started' }, opts('autoInstall')) + s = reduce(s, { type: 'error', message: 'net::ERR_INTERNET_DISCONNECTED' }, opts('autoInstall')) + expect(s.status).toBe('error') + expect(s.error).toBe('net::ERR_INTERNET_DISCONNECTED') + expect(s.lastChecked).toBe(FAKE_NOW) + expect(s.canCheck).toBe(true) + }) + + it('error during downloaded keeps the downloaded state but records the error', () => { + let s = initialState('autoInstall') + s = reduce(s, { type: 'downloaded', version: '4.0.0' }, opts('autoInstall')) + s = reduce(s, { type: 'error', message: 'install failed' }, opts('autoInstall')) + expect(s.status).toBe('downloaded') + expect(s.error).toBe('install failed') + expect(s.canInstall).toBe(true) + }) + + it('error is cleared on a successful subsequent check', () => { + let s = initialState('autoInstall') + s = reduce(s, { type: 'check-started' }, opts('autoInstall')) + s = reduce(s, { type: 'error', message: 'transient' }, opts('autoInstall')) + s = reduce(s, { type: 'check-started' }, opts('autoInstall')) + expect(s.error).toBeUndefined() + + s = reduce(s, { type: 'check-finished-no-update' }, opts('autoInstall')) + expect(s.error).toBeUndefined() + expect(s.status).toBe('up-to-date') + }) + + it('unsupported install mode ignores all transitions except error', () => { + let s = initialState('unsupported') + s = reduce(s, { type: 'check-started' }, opts('unsupported')) + expect(s.status).toBe('unsupported') + + s = reduce(s, { type: 'available', version: 'x' }, opts('unsupported')) + expect(s.status).toBe('unsupported') + + s = reduce(s, { type: 'error', message: 'broke' }, opts('unsupported')) + // error still updates lastChecked + error message even on unsupported, + // because we surface failures consistently. + expect(s.error).toBe('broke') + }) + + it('download progress clamps to 0-100 and rounds', () => { + let s = initialState('autoInstall') + s = reduce(s, { type: 'download-progress', percent: -3 }, opts('autoInstall')) + expect(s.downloadProgress).toBe(0) + + s = reduce(s, { type: 'download-progress', percent: 250 }, opts('autoInstall')) + expect(s.downloadProgress).toBe(100) + + s = reduce(s, { type: 'download-progress', percent: 42.6 }, opts('autoInstall')) + expect(s.downloadProgress).toBe(43) + + s = reduce(s, { type: 'download-progress', percent: NaN }, opts('autoInstall')) + expect(s.downloadProgress).toBe(0) + }) + + it('shouldSkipPeriodicCheck guards correctly', () => { + expect(shouldSkipPeriodicCheck('idle')).toBe(false) + expect(shouldSkipPeriodicCheck('up-to-date')).toBe(false) + expect(shouldSkipPeriodicCheck('error')).toBe(false) + expect(shouldSkipPeriodicCheck('checking')).toBe(true) + expect(shouldSkipPeriodicCheck('downloading')).toBe(true) + expect(shouldSkipPeriodicCheck('downloaded')).toBe(true) + expect(shouldSkipPeriodicCheck('installing')).toBe(true) + expect(shouldSkipPeriodicCheck('unsupported')).toBe(true) + }) + + it('late `available` for the same version does not regress downloaded', () => { + let s = initialState('autoInstall') + s = reduce(s, { type: 'downloaded', version: '5.0.0' }, opts('autoInstall')) + const before = s + s = reduce(s, { type: 'available', version: '5.0.0' }, opts('autoInstall')) + expect(s).toBe(before) + expect(s.canInstall).toBe(true) + }) + + it('late `available` for a *newer* version does refresh', () => { + let s = initialState('autoInstall') + s = reduce(s, { type: 'downloaded', version: '5.0.0' }, opts('autoInstall')) + s = reduce(s, { type: 'available', version: '5.1.0' }, opts('autoInstall')) + expect(s.status).toBe('available') + expect(s.version).toBe('5.1.0') + }) + + it('`available` is ignored entirely while installing', () => { + let s = initialState('autoInstall') + s = reduce(s, { type: 'downloaded', version: '6.0.0' }, opts('autoInstall')) + s = reduce(s, { type: 'install-started' }, opts('autoInstall')) + const before = s + s = reduce(s, { type: 'available', version: '6.1.0' }, opts('autoInstall')) + expect(s).toBe(before) + expect(s.status).toBe('installing') + }) + + it('late download-progress does not regress downloaded or installing', () => { + let s = initialState('autoInstall') + s = reduce(s, { type: 'downloaded', version: '7.0.0' }, opts('autoInstall')) + const beforeDl = s + s = reduce(s, { type: 'download-progress', percent: 50 }, opts('autoInstall')) + expect(s).toBe(beforeDl) + expect(s.status).toBe('downloaded') + + s = reduce(s, { type: 'install-started' }, opts('autoInstall')) + const beforeInst = s + s = reduce(s, { type: 'download-progress', percent: 90 }, opts('autoInstall')) + expect(s).toBe(beforeInst) + expect(s.status).toBe('installing') + }) + + it('late `downloaded` does not undo installing', () => { + let s = initialState('autoInstall') + s = reduce(s, { type: 'downloaded', version: '8.0.0' }, opts('autoInstall')) + s = reduce(s, { type: 'install-started' }, opts('autoInstall')) + const before = s + s = reduce(s, { type: 'downloaded', version: '8.0.0' }, opts('autoInstall')) + expect(s).toBe(before) + expect(s.status).toBe('installing') + }) +}) diff --git a/tsconfig.web.json b/tsconfig.web.json index 5b8ac55..341a100 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -5,7 +5,8 @@ "src/renderer/src/**/*", "src/renderer/src/**/*.tsx", "src/preload/*.d.ts", - "src/preload/attentionTypes.ts" + "src/preload/attentionTypes.ts", + "src/preload/updateTypes.ts" ], "compilerOptions": { "composite": true,