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,