From 540aefc3d375f519f41e737ab1b18fe85e6c3b9d Mon Sep 17 00:00:00 2001 From: matt Date: Wed, 11 Feb 2026 21:23:40 +0900 Subject: [PATCH] Implement auto-update functionality and enhance build configuration - Integrated electron-updater for automatic updates, including IPC handlers for checking, downloading, and installing updates. - Updated electron-builder configuration to include entitlements and notarization scripts for macOS. - Enhanced README with installation instructions and updated features. - Added new components for update notifications and dialogs in the renderer. - Improved CI workflow permissions for better release management. --- .github/workflows/release.yml | 3 + README.md | 162 ++++++++++++------ build/entitlements.mac.plist | 12 ++ electron-builder.yml | 5 +- package.json | 1 + pnpm-lock.yaml | 46 +++++ scripts/notarize.cjs | 22 +++ src/main/index.ts | 26 ++- src/main/ipc/handlers.ts | 12 +- src/main/ipc/updater.ts | 75 ++++++++ .../services/infrastructure/UpdaterService.ts | 118 +++++++++++++ src/main/services/infrastructure/index.ts | 1 + src/preload/constants/ipcChannels.ts | 16 ++ src/preload/index.ts | 25 +++ .../components/common/UpdateBanner.tsx | 71 ++++++++ .../components/common/UpdateDialog.tsx | 89 ++++++++++ .../components/layout/TabbedLayout.tsx | 20 ++- .../settings/sections/AdvancedSection.tsx | 83 ++++++++- src/renderer/store/index.ts | 48 ++++++ src/renderer/store/slices/updateSlice.ts | 82 +++++++++ src/renderer/store/types.ts | 4 +- src/shared/types/api.ts | 28 +++ 22 files changed, 880 insertions(+), 69 deletions(-) create mode 100644 build/entitlements.mac.plist create mode 100644 scripts/notarize.cjs create mode 100644 src/main/ipc/updater.ts create mode 100644 src/main/services/infrastructure/UpdaterService.ts create mode 100644 src/renderer/components/common/UpdateBanner.tsx create mode 100644 src/renderer/components/common/UpdateDialog.tsx create mode 100644 src/renderer/store/slices/updateSlice.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aef5e755..1f97b3f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,9 @@ on: - 'v*' workflow_dispatch: +permissions: + contents: write + jobs: package-mac: runs-on: macos-latest diff --git a/README.md b/README.md index 2f67f067..11049db1 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,134 @@ -# Claude Code Context +

+ Claude Code Context +

-Desktop app for exploring Claude Code session context usage. +

Claude Code Context

-It helps you inspect session timelines, search across sessions, debug context injections (`CLAUDE.md`, mentioned files, tool outputs), and configure notification triggers. +

+ Stop guessing. See exactly what Claude is doing. +
+ A desktop app that turns Claude Code's opaque session logs into a visual, searchable, actionable interface. +

-## Features -- Repository/worktree-aware project grouping -- Session search with context snippets -- Structured conversation/chunk parsing from Claude JSONL logs -- Context usage inspection (CLAUDE.md + mentioned files + tool output) -- Native notifications with configurable trigger rules -- Real-time updates from Claude session/todo file changes +

+ License: MIT + Platform + Electron + React + TypeScript +

-## Tech Stack -- Electron + electron-vite -- React + TypeScript + Zustand -- Tailwind CSS -- Vitest + ESLint +
-## Requirements -- Node.js 20+ -- pnpm 10+ -- macOS or Windows +

+ + Claude Code Context Demo +

+ +--- + +## Why This Exists + +There are many GUI wrappers for Claude Code — Conductor, Craft Agents, Vibe Kanban, 1Code, ccswitch, and others. I tried them all. None of them solved the actual problem: + +**They wrap Claude Code.** They inject their own prompts, add their own abstractions, and change how Claude behaves. If you love the terminal — and I do — you don't want that. You want Claude Code exactly as it is. + +**They only show their own sessions.** Run something in the terminal? It doesn't exist in their UI. You can only see what was executed through *their* tool. The terminal and the GUI are two separate worlds. + +**You can't debug what went wrong.** A session failed — but why? The context filled up too fast — but what consumed it? A subagent spawned 5 child agents — but what did they do? Even in the terminal, scrolling back through a long session to reconstruct what happened is nearly impossible. + +**You can't monitor what matters.** Want to know when Claude reads `.env`? When a single tool call exceeds 4K tokens of context? When a teammate sends a shutdown request? You'd have to wire up hooks manually, every time, for every project. + +**Claude Code Context takes a different approach.** It doesn't wrap or modify Claude Code at all. It reads the session logs that already exist on your machine (`~/.claude/`) and turns them into a rich, interactive interface — regardless of whether the session ran in the terminal, in an IDE, or through another tool. + +> Zero configuration. No API keys. Works with every session you've ever run. + +--- + +## Key Features + +### :mag: Visible Context Tracking + +See exactly what's eating your context window. The **Session Context Panel** breaks down token usage across 6 categories — CLAUDE.md files, @-mentioned files, tool outputs, extended thinking, team coordination, and user messages — so you can instantly identify what's consuming tokens and optimize your workflow. + +### :hammer_and_wrench: Rich Tool Call Inspector + +Every tool call is paired with its result in an expandable card. Specialized viewers render each tool natively: +- **Read** calls show syntax-highlighted code with line numbers +- **Edit** calls show inline diffs with added/removed highlighting +- **Bash** calls show command output +- **Subagent** calls show the full execution tree, expandable in-place + +### :bell: Custom Notification Triggers + +Define rules for when you want to be notified. Match on regex patterns, assign colors, and filter your inbox by trigger. Built-in triggers catch common errors out of the box; add your own for project-specific patterns. + +### :busts_in_silhouette: Team & Subagent Visualization + +When Claude uses multi-agent orchestration, see the full picture. Teammate messages render as color-coded cards. Subagent sessions are expandable inline with their own execution traces, metrics, and tool calls. + +### :zap: Command Palette & Cross-Session Search + +Hit **Cmd+K** for a Spotlight-style command palette. Search across all sessions in a project — results show context snippets with highlighted keywords. Navigate directly to the exact message. + +### :bar_chart: Multi-Pane Layout + +Open multiple sessions side-by-side. Drag-and-drop tabs between panes, split views, and compare sessions in parallel — like a proper IDE for your AI conversations. + +--- ## Getting Started + +### Prerequisites + +- **Node.js** 20+ +- **pnpm** 10+ +- macOS or Windows + +### Install & Run + ```bash +git clone https://github.com/matt1398/claude-code-context.git +cd claude-code-context pnpm install pnpm dev ``` -## Data Source -The app reads Claude local data from: -- `~/.claude/projects/` -- `~/.claude/todos/` +That's it. The app auto-discovers your Claude Code projects from `~/.claude/`. + +### Build for Distribution -## Scripts ```bash -pnpm dev # Run app in development -pnpm typecheck # TypeScript checks -pnpm lint # ESLint (no auto-fix) -pnpm test # Unit tests -pnpm build # Electron/Vite production build -pnpm check # Full local quality gate -pnpm dist:mac # Package macOS app (electron-builder) -pnpm dist:win # Package Windows app (electron-builder) -pnpm dist # Package both targets +pnpm dist:mac # macOS (.dmg) +pnpm dist:win # Windows (.exe) +pnpm dist # Both platforms ``` -## Packaging and Release -- Packaging is configured with `electron-builder.yml`. -- CI workflow (`.github/workflows/ci.yml`) runs typecheck/lint/test/build on macOS + Windows. -- Release workflow (`.github/workflows/release.yml`) builds distributables on tags (`v*`). -- Code signing/notarization uses GitHub secrets: - - `CSC_LINK`, `CSC_KEY_PASSWORD` - - `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID` (macOS notarization) +--- -## Security Notes -- IPC handlers validate IDs/inputs and apply strict path containment checks. -- File reads for context injection are constrained to project root and `~/.claude`. -- Sensitive credential path patterns are blocked. +## Scripts + +| Command | Description | +|---------|-------------| +| `pnpm dev` | Development with hot reload | +| `pnpm build` | Production build | +| `pnpm typecheck` | TypeScript type checking | +| `pnpm lint:fix` | Lint and auto-fix | +| `pnpm test` | Run all tests | +| `pnpm test:watch` | Watch mode | +| `pnpm test:coverage` | Coverage report | +| `pnpm check` | Full quality gate (types + lint + test + build) | + +--- ## Contributing -See: -- `CONTRIBUTING.md` -- `CODE_OF_CONDUCT.md` -- `SECURITY.md` + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines. Please read our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Security + +IPC handlers validate all inputs with strict path containment checks. File reads are constrained to the project root and `~/.claude`. Sensitive credential paths are blocked. See [SECURITY.md](SECURITY.md) for details. ## License -MIT (`LICENSE`) + +[MIT](LICENSE) diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist new file mode 100644 index 00000000..90031d93 --- /dev/null +++ b/build/entitlements.mac.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + + diff --git a/electron-builder.yml b/electron-builder.yml index 61c1c22b..5ccd7770 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -22,8 +22,9 @@ mac: hardenedRuntime: true gatekeeperAssess: false icon: resources/icons/mac/icon.icns - notarize: - teamId: ${env.APPLE_TEAM_ID} + entitlements: build/entitlements.mac.plist + entitlementsInherit: build/entitlements.mac.plist + afterSign: scripts/notarize.cjs dmg: sign: false diff --git a/package.json b/package.json index 369a9be3..55190386 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-virtual": "^3.10.8", "date-fns": "^3.6.0", + "electron-updater": "^6.7.3", "lucide-react": "^0.562.0", "mdast-util-to-hast": "^13.2.1", "react": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef59154a..f41a9066 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: date-fns: specifier: ^3.6.0 version: 3.6.0 + electron-updater: + specifier: ^6.7.3 + version: 6.7.3 lucide-react: specifier: ^0.562.0 version: 0.562.0(react@18.3.1) @@ -1532,6 +1535,10 @@ packages: resolution: {integrity: sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==} engines: {node: '>=12.0.0'} + builder-util-runtime@9.5.1: + resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} + engines: {node: '>=12.0.0'} + builder-util@24.13.1: resolution: {integrity: sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==} @@ -1830,6 +1837,9 @@ packages: electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + electron-updater@6.7.3: + resolution: {integrity: sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==} + electron-vite@2.3.0: resolution: {integrity: sha512-lsN2FymgJlp4k6MrcsphGqZQ9fKRdJKasoaiwIrAewN1tapYI/KINLdfEL7n10LuF0pPSNf/IqjzZbB5VINctg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -2679,9 +2689,16 @@ packages: lodash.difference@4.5.0: resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.flatten@4.4.0: resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} @@ -3634,6 +3651,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -5308,6 +5328,13 @@ snapshots: transitivePeerDependencies: - supports-color + builder-util-runtime@9.5.1: + dependencies: + debug: 4.4.3 + sax: 1.4.4 + transitivePeerDependencies: + - supports-color + builder-util@24.13.1: dependencies: 7zip-bin: 5.2.0 @@ -5653,6 +5680,19 @@ snapshots: electron-to-chromium@1.5.267: {} + electron-updater@6.7.3: + dependencies: + builder-util-runtime: 9.5.1 + fs-extra: 10.1.0 + js-yaml: 4.1.1 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.3 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + electron-vite@2.3.0(vite@5.4.21(@types/node@25.0.7)): dependencies: '@babel/core': 7.28.6 @@ -6757,8 +6797,12 @@ snapshots: lodash.difference@4.5.0: {} + lodash.escaperegexp@4.1.2: {} + lodash.flatten@4.4.0: {} + lodash.isequal@4.5.0: {} + lodash.isplainobject@4.0.6: {} lodash.merge@4.6.2: {} @@ -8017,6 +8061,8 @@ snapshots: dependencies: any-promise: 1.3.0 + tiny-typed-emitter@2.1.0: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} diff --git a/scripts/notarize.cjs b/scripts/notarize.cjs new file mode 100644 index 00000000..7a7af681 --- /dev/null +++ b/scripts/notarize.cjs @@ -0,0 +1,22 @@ +const { notarize } = require('@electron/notarize'); + +exports.default = async function notarizing(context) { + const { electronPlatformName, appOutDir } = context; + if (electronPlatformName !== 'darwin') return; + + if (!process.env.APPLE_ID || !process.env.APPLE_APP_SPECIFIC_PASSWORD || !process.env.APPLE_TEAM_ID) { + console.log('Skipping notarization: Apple credentials not set'); + return; + } + + const appName = context.packager.appInfo.productFilename; + + return await notarize({ + tool: 'notarytool', + appBundleId: 'com.claudecode.context', + appPath: `${appOutDir}/${appName}.app`, + appleId: process.env.APPLE_ID, + appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, + teamId: process.env.APPLE_TEAM_ID, + }); +}; diff --git a/src/main/index.ts b/src/main/index.ts index 6aa253de..c90a3f1f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -44,6 +44,7 @@ import { ProjectScanner, SessionParser, SubagentResolver, + UpdaterService, } from './services'; // ============================================================================= @@ -60,6 +61,7 @@ let chunkBuilder: ChunkBuilder; let dataCache: DataCache; let fileWatcher: FileWatcher; let notificationManager: NotificationManager; +let updaterService: UpdaterService; let cleanupInterval: NodeJS.Timeout | null = null; /** @@ -75,11 +77,19 @@ function initializeServices(): void { chunkBuilder = new ChunkBuilder(); const disableCache = process.env.CLAUDE_CONTEXT_DISABLE_CACHE === '1'; dataCache = new DataCache(MAX_CACHE_SESSIONS, CACHE_TTL_MINUTES, !disableCache); + updaterService = new UpdaterService(); logger.info(`Projects directory: ${projectScanner.getProjectsDir()}`); // Initialize IPC handlers - initializeIpcHandlers(projectScanner, sessionParser, subagentResolver, chunkBuilder, dataCache); + initializeIpcHandlers( + projectScanner, + sessionParser, + subagentResolver, + chunkBuilder, + dataCache, + updaterService + ); // Initialize notification manager using singleton pattern // This ensures IPC handlers and FileWatcher use the same instance @@ -171,10 +181,12 @@ function createWindow(): void { void mainWindow.loadFile(join(__dirname, '../renderer/index.html')); } - // Set traffic light position + notify renderer on first load + // Set traffic light position + notify renderer on first load, and auto-check for updates mainWindow.webContents.on('did-finish-load', () => { if (mainWindow && !mainWindow.isDestroyed()) { syncTrafficLightPosition(mainWindow); + // Auto-check for updates 3 seconds after window loads + setTimeout(() => updaterService.checkForUpdates(), 3000); } }); @@ -214,10 +226,13 @@ function createWindow(): void { mainWindow.on('closed', () => { mainWindow = null; - // Clear main window reference from notification manager + // Clear main window references if (notificationManager) { notificationManager.setMainWindow(null); } + if (updaterService) { + updaterService.setMainWindow(null); + } }); // Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event) @@ -226,10 +241,13 @@ function createWindow(): void { // Could show an error dialog or attempt to reload the window }); - // Set main window reference for notification manager + // Set main window reference for notification manager and updater if (notificationManager) { notificationManager.setMainWindow(mainWindow); } + if (updaterService) { + updaterService.setMainWindow(mainWindow); + } logger.info('Main window created'); } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index cdc6b905..e0f59226 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -35,6 +35,11 @@ import { registerSubagentHandlers, removeSubagentHandlers, } from './subagents'; +import { + initializeUpdaterHandlers, + registerUpdaterHandlers, + removeUpdaterHandlers, +} from './updater'; import { registerUtilityHandlers, removeUtilityHandlers } from './utility'; import { registerValidationHandlers, removeValidationHandlers } from './validation'; @@ -44,6 +49,7 @@ import type { ProjectScanner, SessionParser, SubagentResolver, + UpdaterService, } from '../services'; /** @@ -54,13 +60,15 @@ export function initializeIpcHandlers( parser: SessionParser, resolver: SubagentResolver, builder: ChunkBuilder, - cache: DataCache + cache: DataCache, + updater: UpdaterService ): void { // Initialize domain handlers with their required services initializeProjectHandlers(scanner); initializeSessionHandlers(scanner, parser, resolver, builder, cache); initializeSearchHandlers(scanner); initializeSubagentHandlers(builder, cache, parser, resolver); + initializeUpdaterHandlers(updater); // Register all handlers registerProjectHandlers(ipcMain); @@ -71,6 +79,7 @@ export function initializeIpcHandlers( registerUtilityHandlers(ipcMain); registerNotificationHandlers(ipcMain); registerConfigHandlers(ipcMain); + registerUpdaterHandlers(ipcMain); logger.info('All handlers registered'); } @@ -88,6 +97,7 @@ export function removeIpcHandlers(): void { removeUtilityHandlers(ipcMain); removeNotificationHandlers(ipcMain); removeConfigHandlers(ipcMain); + removeUpdaterHandlers(ipcMain); logger.info('All handlers removed'); } diff --git a/src/main/ipc/updater.ts b/src/main/ipc/updater.ts new file mode 100644 index 00000000..7c709ca0 --- /dev/null +++ b/src/main/ipc/updater.ts @@ -0,0 +1,75 @@ +/** + * IPC Handlers for Update Operations. + * + * Handlers: + * - updater:check: Check for available updates + * - updater:download: Download the available update + * - updater:install: Quit and install the downloaded update + */ + +import { getErrorMessage } from '@shared/utils/errorHandling'; +import { createLogger } from '@shared/utils/logger'; +import { type IpcMain, type IpcMainInvokeEvent } from 'electron'; + +import type { UpdaterService } from '../services'; + +const logger = createLogger('IPC:updater'); + +let updaterService: UpdaterService; + +/** + * Initializes updater handlers with the service instance. + */ +export function initializeUpdaterHandlers(service: UpdaterService): void { + updaterService = service; +} + +/** + * Registers all updater-related IPC handlers. + */ +export function registerUpdaterHandlers(ipcMain: IpcMain): void { + ipcMain.handle('updater:check', handleCheck); + ipcMain.handle('updater:download', handleDownload); + ipcMain.handle('updater:install', handleInstall); + + logger.info('Updater handlers registered'); +} + +/** + * Removes all updater IPC handlers. + */ +export function removeUpdaterHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler('updater:check'); + ipcMain.removeHandler('updater:download'); + ipcMain.removeHandler('updater:install'); + + logger.info('Updater handlers removed'); +} + +// ============================================================================= +// Handler Implementations +// ============================================================================= + +async function handleCheck(_event: IpcMainInvokeEvent): Promise { + try { + await updaterService.checkForUpdates(); + } catch (error) { + logger.error('Error in updater:check:', getErrorMessage(error)); + } +} + +async function handleDownload(_event: IpcMainInvokeEvent): Promise { + try { + await updaterService.downloadUpdate(); + } catch (error) { + logger.error('Error in updater:download:', getErrorMessage(error)); + } +} + +function handleInstall(_event: IpcMainInvokeEvent): void { + try { + updaterService.quitAndInstall(); + } catch (error) { + logger.error('Error in updater:install:', getErrorMessage(error)); + } +} diff --git a/src/main/services/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts new file mode 100644 index 00000000..50eb7566 --- /dev/null +++ b/src/main/services/infrastructure/UpdaterService.ts @@ -0,0 +1,118 @@ +/** + * UpdaterService - Wraps electron-updater's autoUpdater for OTA updates. + * + * Forwards update lifecycle events to the renderer via IPC. + * Auto-download is disabled so users must confirm before downloading. + */ + +import { getErrorMessage } from '@shared/utils/errorHandling'; +import { createLogger } from '@shared/utils/logger'; +import electronUpdater from 'electron-updater'; + +const { autoUpdater } = electronUpdater; + +import type { UpdaterStatus } from '@shared/types'; +import type { BrowserWindow } from 'electron'; + +const logger = createLogger('UpdaterService'); + +export class UpdaterService { + private mainWindow: BrowserWindow | null = null; + + constructor() { + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = true; + + this.bindEvents(); + } + + /** + * Set the main window reference for sending status events. + */ + setMainWindow(window: BrowserWindow | null): void { + this.mainWindow = window; + } + + /** + * Check for available updates. + */ + async checkForUpdates(): Promise { + try { + await autoUpdater.checkForUpdates(); + } catch (error) { + logger.error('Check for updates failed:', getErrorMessage(error)); + } + } + + /** + * Download the available update. + */ + async downloadUpdate(): Promise { + try { + await autoUpdater.downloadUpdate(); + } catch (error) { + logger.error('Download update failed:', getErrorMessage(error)); + } + } + + /** + * Quit the app and install the downloaded update. + */ + quitAndInstall(): void { + autoUpdater.quitAndInstall(); + } + + private sendStatus(status: UpdaterStatus): void { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send('updater:status', status); + } + } + + private bindEvents(): void { + autoUpdater.on('checking-for-update', () => { + logger.info('Checking for update...'); + this.sendStatus({ type: 'checking' }); + }); + + autoUpdater.on('update-available', (info) => { + logger.info('Update available:', info.version); + this.sendStatus({ + type: 'available', + version: info.version, + releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, + }); + }); + + autoUpdater.on('update-not-available', () => { + logger.info('No update available'); + this.sendStatus({ type: 'not-available' }); + }); + + autoUpdater.on('download-progress', (progress) => { + this.sendStatus({ + type: 'downloading', + progress: { + percent: progress.percent, + transferred: progress.transferred, + total: progress.total, + }, + }); + }); + + autoUpdater.on('update-downloaded', (info) => { + logger.info('Update downloaded:', info.version); + this.sendStatus({ + type: 'downloaded', + version: info.version, + }); + }); + + autoUpdater.on('error', (error) => { + logger.error('Updater error:', getErrorMessage(error)); + this.sendStatus({ + type: 'error', + error: getErrorMessage(error), + }); + }); + } +} diff --git a/src/main/services/infrastructure/index.ts b/src/main/services/infrastructure/index.ts index 8a928c7d..78cb02b0 100644 --- a/src/main/services/infrastructure/index.ts +++ b/src/main/services/infrastructure/index.ts @@ -14,3 +14,4 @@ export * from './DataCache'; export * from './FileWatcher'; export * from './NotificationManager'; export * from './TriggerManager'; +export * from './UpdaterService'; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 845dee5a..6e373e1c 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -58,3 +58,19 @@ export const CONFIG_PIN_SESSION = 'config:pinSession'; /** Unpin a session */ export const CONFIG_UNPIN_SESSION = 'config:unpinSession'; + +// ============================================================================= +// Updater API Channels +// ============================================================================= + +/** Check for updates */ +export const UPDATER_CHECK = 'updater:check'; + +/** Download available update */ +export const UPDATER_DOWNLOAD = 'updater:download'; + +/** Quit and install downloaded update */ +export const UPDATER_INSTALL = 'updater:install'; + +/** Status event channel (main -> renderer) */ +export const UPDATER_STATUS = 'updater:status'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 3d1ce1e1..1f114471 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,12 @@ import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants'; import { contextBridge, ipcRenderer } from 'electron'; +import { + UPDATER_CHECK, + UPDATER_DOWNLOAD, + UPDATER_INSTALL, + UPDATER_STATUS, +} from './constants/ipcChannels'; import { CONFIG_ADD_IGNORE_REGEX, CONFIG_ADD_IGNORE_REPOSITORY, @@ -285,6 +291,25 @@ const electronAPI: ElectronAPI = { ipcRenderer.removeListener('todo-change', listener); }; }, + + // Updater API + updater: { + check: () => ipcRenderer.invoke(UPDATER_CHECK), + download: () => ipcRenderer.invoke(UPDATER_DOWNLOAD), + install: () => ipcRenderer.invoke(UPDATER_INSTALL), + onStatus: (callback: (event: unknown, status: unknown) => void): (() => void) => { + ipcRenderer.on( + UPDATER_STATUS, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + return (): void => { + ipcRenderer.removeListener( + UPDATER_STATUS, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + }; + }, + }, }; // Use contextBridge to securely expose the API to the renderer process diff --git a/src/renderer/components/common/UpdateBanner.tsx b/src/renderer/components/common/UpdateBanner.tsx new file mode 100644 index 00000000..f0feaba6 --- /dev/null +++ b/src/renderer/components/common/UpdateBanner.tsx @@ -0,0 +1,71 @@ +/** + * UpdateBanner - Slim top banner for download progress and restart prompt. + * + * Visible during download and after the update is ready to install. + */ + +import { useStore } from '@renderer/store'; +import { CheckCircle, Loader2, X } from 'lucide-react'; + +export const UpdateBanner = (): React.JSX.Element | null => { + const showUpdateBanner = useStore((s) => s.showUpdateBanner); + const updateStatus = useStore((s) => s.updateStatus); + const downloadProgress = useStore((s) => s.downloadProgress); + const availableVersion = useStore((s) => s.availableVersion); + const installUpdate = useStore((s) => s.installUpdate); + const dismissUpdateBanner = useStore((s) => s.dismissUpdateBanner); + + if (!showUpdateBanner || (updateStatus !== 'downloading' && updateStatus !== 'downloaded')) { + return null; + } + + const isDownloading = updateStatus === 'downloading'; + const percent = Math.round(downloadProgress); + + return ( +
+ {isDownloading ? ( + <> + + + Downloading update... {percent}% + +
+
+
+ + ) : ( + <> + + + Update ready{availableVersion ? ` (v${availableVersion})` : ''} + + + + )} + + {/* Dismiss */} + +
+ ); +}; diff --git a/src/renderer/components/common/UpdateDialog.tsx b/src/renderer/components/common/UpdateDialog.tsx new file mode 100644 index 00000000..73ac3d9a --- /dev/null +++ b/src/renderer/components/common/UpdateDialog.tsx @@ -0,0 +1,89 @@ +/** + * UpdateDialog - Modal dialog shown when a new version is available. + * + * Prompts the user to download the update or dismiss it. + */ + +import { useStore } from '@renderer/store'; +import { Download, X } from 'lucide-react'; + +export const UpdateDialog = (): React.JSX.Element | null => { + const showUpdateDialog = useStore((s) => s.showUpdateDialog); + const availableVersion = useStore((s) => s.availableVersion); + const releaseNotes = useStore((s) => s.releaseNotes); + const downloadUpdate = useStore((s) => s.downloadUpdate); + const dismissUpdateDialog = useStore((s) => s.dismissUpdateDialog); + + if (!showUpdateDialog) return null; + + return ( +
+
e.stopPropagation()} + > + {/* Close button */} + + + {/* Title */} +

+ Update Available +

+ + {/* Body */} +

+ Version {availableVersion} is available. Would you like to download it? +

+ + {/* Release notes */} + {releaseNotes && ( +
+ {releaseNotes} +
+ )} + + {/* Actions */} +
+ + +
+
+
+ ); +}; diff --git a/src/renderer/components/layout/TabbedLayout.tsx b/src/renderer/components/layout/TabbedLayout.tsx index fcde1397..3a71aef7 100644 --- a/src/renderer/components/layout/TabbedLayout.tsx +++ b/src/renderer/components/layout/TabbedLayout.tsx @@ -10,6 +10,8 @@ import { getTrafficLightPaddingForZoom } from '@renderer/constants/layout'; import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts'; import { useZoomFactor } from '@renderer/hooks/useZoomFactor'; +import { UpdateBanner } from '../common/UpdateBanner'; +import { UpdateDialog } from '../common/UpdateDialog'; import { CommandPalette } from '../search/CommandPalette'; import { PaneContainer } from './PaneContainer'; @@ -23,19 +25,23 @@ export const TabbedLayout = (): React.JSX.Element => { return (
- {/* Command Palette (Cmd+K) */} - + +
+ {/* Command Palette (Cmd+K) */} + - {/* Sidebar - Project dropdown + Sessions (280px) */} - + {/* Sidebar - Project dropdown + Sessions (280px) */} + - {/* Multi-pane content area */} - + {/* Multi-pane content area */} + +
+
); }; diff --git a/src/renderer/components/settings/sections/AdvancedSection.tsx b/src/renderer/components/settings/sections/AdvancedSection.tsx index 975aff08..2bfc71eb 100644 --- a/src/renderer/components/settings/sections/AdvancedSection.tsx +++ b/src/renderer/components/settings/sections/AdvancedSection.tsx @@ -2,10 +2,11 @@ * AdvancedSection - Advanced settings including config management and about info. */ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import appIcon from '@renderer/favicon.png'; -import { Code2, Download, RefreshCw, Upload } from 'lucide-react'; +import { useStore } from '@renderer/store'; +import { CheckCircle, Code2, Download, Loader2, RefreshCw, Upload } from 'lucide-react'; import { SettingsSectionHeader } from '../components'; @@ -25,11 +26,65 @@ export const AdvancedSection = ({ onOpenInEditor, }: AdvancedSectionProps): React.JSX.Element => { const [version, setVersion] = useState(''); + const updateStatus = useStore((s) => s.updateStatus); + const availableVersion = useStore((s) => s.availableVersion); + const checkForUpdates = useStore((s) => s.checkForUpdates); + + // Auto-revert "not-available" / "error" status back to idle after a brief display + const revertTimerRef = useRef>(); + useEffect(() => { + if (updateStatus === 'not-available' || updateStatus === 'error') { + revertTimerRef.current = setTimeout(() => { + useStore.setState({ updateStatus: 'idle' }); + }, 3000); + } + return () => { + if (revertTimerRef.current) clearTimeout(revertTimerRef.current); + }; + }, [updateStatus]); useEffect(() => { window.electronAPI.getAppVersion().then(setVersion).catch(console.error); }, []); + const handleCheckForUpdates = useCallback(() => { + checkForUpdates(); + }, [checkForUpdates]); + + const getUpdateButtonContent = (): React.JSX.Element => { + switch (updateStatus) { + case 'checking': + return ( + <> + + Checking... + + ); + case 'not-available': + return ( + <> + + Up to date + + ); + case 'available': + case 'downloaded': + return ( + <> + + {updateStatus === 'downloaded' ? 'Update ready' : `v${availableVersion} available`} + + ); + default: + return ( + <> + + Check for Updates + + ); + } + }; + return (
@@ -87,9 +142,27 @@ export const AdvancedSection = ({
App Icon
-

- Claude Code Context -

+
+

+ Claude Code Context +

+ +

Version {version || '...'}

diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 42762c39..cb0c286d 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -16,9 +16,11 @@ import { createSubagentSlice } from './slices/subagentSlice'; import { createTabSlice } from './slices/tabSlice'; import { createTabUISlice } from './slices/tabUISlice'; import { createUISlice } from './slices/uiSlice'; +import { createUpdateSlice } from './slices/updateSlice'; import type { DetectedError } from '../types/data'; import type { AppState } from './types'; +import type { UpdaterStatus } from '@shared/types'; // ============================================================================= // Store Creation @@ -37,6 +39,7 @@ export const useStore = create()((...args) => ({ ...createUISlice(...args), ...createNotificationSlice(...args), ...createConfigSlice(...args), + ...createUpdateSlice(...args), })); // ============================================================================= @@ -226,6 +229,51 @@ export function initializeNotificationListeners(): () => void { } } + // Listen for updater status events from main process + if (window.electronAPI.updater?.onStatus) { + const cleanup = window.electronAPI.updater.onStatus((_event: unknown, status: unknown) => { + const s = status as UpdaterStatus; + switch (s.type) { + case 'checking': + useStore.setState({ updateStatus: 'checking' }); + break; + case 'available': + useStore.setState({ + updateStatus: 'available', + availableVersion: s.version ?? null, + releaseNotes: s.releaseNotes ?? null, + showUpdateDialog: true, + }); + break; + case 'not-available': + useStore.setState({ updateStatus: 'not-available' }); + break; + case 'downloading': + useStore.setState({ + updateStatus: 'downloading', + downloadProgress: s.progress?.percent ?? 0, + }); + break; + case 'downloaded': + useStore.setState({ + updateStatus: 'downloaded', + downloadProgress: 100, + availableVersion: s.version ?? useStore.getState().availableVersion, + }); + break; + case 'error': + useStore.setState({ + updateStatus: 'error', + updateError: s.error ?? 'Unknown error', + }); + break; + } + }); + if (typeof cleanup === 'function') { + cleanupFns.push(cleanup); + } + } + // Return cleanup function return () => { for (const timer of pendingSessionRefreshTimers.values()) { diff --git a/src/renderer/store/slices/updateSlice.ts b/src/renderer/store/slices/updateSlice.ts new file mode 100644 index 00000000..dd9061ae --- /dev/null +++ b/src/renderer/store/slices/updateSlice.ts @@ -0,0 +1,82 @@ +/** + * Update slice - manages OTA auto-update state and actions. + */ + +import { createLogger } from '@shared/utils/logger'; + +import type { AppState } from '../types'; +import type { StateCreator } from 'zustand'; + +const logger = createLogger('Store:update'); + +// ============================================================================= +// Slice Interface +// ============================================================================= + +export interface UpdateSlice { + // State + updateStatus: + | 'idle' + | 'checking' + | 'available' + | 'not-available' + | 'downloading' + | 'downloaded' + | 'error'; + availableVersion: string | null; + releaseNotes: string | null; + downloadProgress: number; + updateError: string | null; + showUpdateDialog: boolean; + showUpdateBanner: boolean; + + // Actions + checkForUpdates: () => void; + downloadUpdate: () => void; + installUpdate: () => void; + dismissUpdateDialog: () => void; + dismissUpdateBanner: () => void; +} + +// ============================================================================= +// Slice Creator +// ============================================================================= + +export const createUpdateSlice: StateCreator = (set) => ({ + // Initial state + updateStatus: 'idle', + availableVersion: null, + releaseNotes: null, + downloadProgress: 0, + updateError: null, + showUpdateDialog: false, + showUpdateBanner: false, + + checkForUpdates: () => { + set({ updateStatus: 'checking', updateError: null }); + window.electronAPI.updater.check().catch((error) => { + logger.error('Failed to check for updates:', error); + }); + }, + + downloadUpdate: () => { + set({ showUpdateDialog: false, showUpdateBanner: true, downloadProgress: 0 }); + window.electronAPI.updater.download().catch((error) => { + logger.error('Failed to download update:', error); + }); + }, + + installUpdate: () => { + window.electronAPI.updater.install().catch((error) => { + logger.error('Failed to install update:', error); + }); + }, + + dismissUpdateDialog: () => { + set({ showUpdateDialog: false }); + }, + + dismissUpdateBanner: () => { + set({ showUpdateBanner: false }); + }, +}); diff --git a/src/renderer/store/types.ts b/src/renderer/store/types.ts index de0bb833..6b1c2ce1 100644 --- a/src/renderer/store/types.ts +++ b/src/renderer/store/types.ts @@ -15,6 +15,7 @@ import type { SubagentSlice } from './slices/subagentSlice'; import type { TabSlice } from './slices/tabSlice'; import type { TabUISlice } from './slices/tabUISlice'; import type { UISlice } from './slices/uiSlice'; +import type { UpdateSlice } from './slices/updateSlice'; // ============================================================================= // Shared Types @@ -84,4 +85,5 @@ export type AppState = ProjectSlice & PaneSlice & UISlice & NotificationSlice & - ConfigSlice; + ConfigSlice & + UpdateSlice; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index c60727ed..4748f54b 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -119,6 +119,31 @@ export interface ClaudeMdFileInfo { estimatedTokens: number; } +// ============================================================================= +// Updater API +// ============================================================================= + +/** + * Status payload sent from the main process updater to the renderer. + */ +export interface UpdaterStatus { + type: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'; + version?: string; + releaseNotes?: string; + progress?: { percent: number; transferred: number; total: number }; + error?: string; +} + +/** + * Updater API exposed via preload. + */ +export interface UpdaterAPI { + check: () => Promise; + download: () => Promise; + install: () => Promise; + onStatus: (callback: (event: unknown, status: unknown) => void) => () => void; +} + // ============================================================================= // Main Electron API // ============================================================================= @@ -197,6 +222,9 @@ export interface ElectronAPI { projectRoot?: string ) => Promise<{ success: boolean; error?: string }>; openExternal: (url: string) => Promise<{ success: boolean; error?: string }>; + + // Updater API + updater: UpdaterAPI; } // =============================================================================