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.
This commit is contained in:
parent
fc48f6e099
commit
540aefc3d3
22 changed files with 880 additions and 69 deletions
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
|
|
@ -6,6 +6,9 @@ on:
|
|||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
package-mac:
|
||||
runs-on: macos-latest
|
||||
|
|
|
|||
162
README.md
162
README.md
|
|
@ -1,70 +1,134 @@
|
|||
# Claude Code Context
|
||||
<p align="center">
|
||||
<img src="resources/icons/png/128x128.png" alt="Claude Code Context" width="120" />
|
||||
</p>
|
||||
|
||||
Desktop app for exploring Claude Code session context usage.
|
||||
<h1 align="center">Claude Code Context</h1>
|
||||
|
||||
It helps you inspect session timelines, search across sessions, debug context injections (`CLAUDE.md`, mentioned files, tool outputs), and configure notification triggers.
|
||||
<p align="center">
|
||||
<strong>Stop guessing. See exactly what Claude is doing.</strong>
|
||||
<br />
|
||||
A desktop app that turns Claude Code's opaque session logs into a visual, searchable, actionable interface.
|
||||
</p>
|
||||
|
||||
## 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
|
||||
<p align="center">
|
||||
<a href="https://github.com/matt1398/claude-code-context/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT" /></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows-lightgrey.svg" alt="Platform" /></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/electron-40-47848F.svg?logo=electron" alt="Electron" /></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/react-18-61DAFB.svg?logo=react" alt="React" /></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/typescript-5-3178C6.svg?logo=typescript" alt="TypeScript" /></a>
|
||||
</p>
|
||||
|
||||
## Tech Stack
|
||||
- Electron + electron-vite
|
||||
- React + TypeScript + Zustand
|
||||
- Tailwind CSS
|
||||
- Vitest + ESLint
|
||||
<br />
|
||||
|
||||
## Requirements
|
||||
- Node.js 20+
|
||||
- pnpm 10+
|
||||
- macOS or Windows
|
||||
<p align="center">
|
||||
<!-- TODO: Replace with actual demo GIF/video -->
|
||||
<img src="docs/assets/demo.gif" alt="Claude Code Context Demo" width="900" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
|
|
|||
12
build/entitlements.mac.plist
Normal file
12
build/entitlements.mac.plist
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
22
scripts/notarize.cjs
Normal file
22
scripts/notarize.cjs
Normal file
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
75
src/main/ipc/updater.ts
Normal file
75
src/main/ipc/updater.ts
Normal file
|
|
@ -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<void> {
|
||||
try {
|
||||
await updaterService.checkForUpdates();
|
||||
} catch (error) {
|
||||
logger.error('Error in updater:check:', getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload(_event: IpcMainInvokeEvent): Promise<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
118
src/main/services/infrastructure/UpdaterService.ts
Normal file
118
src/main/services/infrastructure/UpdaterService.ts
Normal file
|
|
@ -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<void> {
|
||||
try {
|
||||
await autoUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
logger.error('Check for updates failed:', getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the available update.
|
||||
*/
|
||||
async downloadUpdate(): Promise<void> {
|
||||
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),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -14,3 +14,4 @@ export * from './DataCache';
|
|||
export * from './FileWatcher';
|
||||
export * from './NotificationManager';
|
||||
export * from './TriggerManager';
|
||||
export * from './UpdaterService';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
71
src/renderer/components/common/UpdateBanner.tsx
Normal file
71
src/renderer/components/common/UpdateBanner.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className="relative flex items-center gap-3 border-b px-4 py-2 text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-blue-400" />
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Downloading update... {percent}%
|
||||
</span>
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500 transition-all duration-300"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="size-4 shrink-0 text-green-400" />
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Update ready{availableVersion ? ` (v${availableVersion})` : ''}
|
||||
</span>
|
||||
<button
|
||||
onClick={installUpdate}
|
||||
className="ml-auto rounded-md bg-green-600 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-green-500"
|
||||
>
|
||||
Restart to Update
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dismiss */}
|
||||
<button
|
||||
onClick={dismissUpdateBanner}
|
||||
className="shrink-0 rounded p-0.5 transition-colors hover:bg-white/10"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
89
src/renderer/components/common/UpdateDialog.tsx
Normal file
89
src/renderer/components/common/UpdateDialog.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
|
||||
onClick={dismissUpdateDialog}
|
||||
>
|
||||
<div
|
||||
className="relative mx-4 w-full max-w-md rounded-lg border p-6 shadow-xl"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-overlay)',
|
||||
borderColor: 'var(--color-border-emphasis)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={dismissUpdateDialog}
|
||||
className="absolute right-3 top-3 rounded p-1 transition-colors hover:bg-white/10"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Update Available
|
||||
</h2>
|
||||
|
||||
{/* Body */}
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Version {availableVersion} is available. Would you like to download it?
|
||||
</p>
|
||||
|
||||
{/* Release notes */}
|
||||
{releaseNotes && (
|
||||
<div
|
||||
className="mt-3 max-h-40 overflow-y-auto rounded border p-3 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
{releaseNotes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-5 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={dismissUpdateDialog}
|
||||
className="rounded-md border px-4 py-2 text-sm font-medium transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
onClick={downloadUpdate}
|
||||
className="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-500"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Download Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className="flex h-screen bg-claude-dark-bg text-claude-dark-text"
|
||||
className="flex h-screen flex-col bg-claude-dark-bg text-claude-dark-text"
|
||||
style={
|
||||
{ '--macos-traffic-light-padding-left': `${trafficLightPadding}px` } as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Command Palette (Cmd+K) */}
|
||||
<CommandPalette />
|
||||
<UpdateBanner />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Command Palette (Cmd+K) */}
|
||||
<CommandPalette />
|
||||
|
||||
{/* Sidebar - Project dropdown + Sessions (280px) */}
|
||||
<Sidebar />
|
||||
{/* Sidebar - Project dropdown + Sessions (280px) */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Multi-pane content area */}
|
||||
<PaneContainer />
|
||||
{/* Multi-pane content area */}
|
||||
<PaneContainer />
|
||||
</div>
|
||||
<UpdateDialog />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string>('');
|
||||
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<ReturnType<typeof setTimeout>>();
|
||||
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 (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking...
|
||||
</>
|
||||
);
|
||||
case 'not-available':
|
||||
return (
|
||||
<>
|
||||
<CheckCircle className="size-3.5" />
|
||||
Up to date
|
||||
</>
|
||||
);
|
||||
case 'available':
|
||||
case 'downloaded':
|
||||
return (
|
||||
<>
|
||||
<Download className="size-3.5" />
|
||||
{updateStatus === 'downloaded' ? 'Update ready' : `v${availableVersion} available`}
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Check for Updates
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsSectionHeader title="Configuration" />
|
||||
|
|
@ -87,9 +142,27 @@ export const AdvancedSection = ({
|
|||
<div className="flex items-start gap-4 py-3">
|
||||
<img src={appIcon} alt="App Icon" className="size-10 rounded-lg" />
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
Claude Code Context
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
Claude Code Context
|
||||
</p>
|
||||
<button
|
||||
onClick={handleCheckForUpdates}
|
||||
disabled={updateStatus === 'checking'}
|
||||
className="flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color:
|
||||
updateStatus === 'not-available'
|
||||
? 'var(--color-text-muted)'
|
||||
: updateStatus === 'available' || updateStatus === 'downloaded'
|
||||
? '#60a5fa'
|
||||
: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{getUpdateButtonContent()}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Version {version || '...'}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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<AppState>()((...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()) {
|
||||
|
|
|
|||
82
src/renderer/store/slices/updateSlice.ts
Normal file
82
src/renderer/store/slices/updateSlice.ts
Normal file
|
|
@ -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<AppState, [], [], UpdateSlice> = (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 });
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
download: () => Promise<void>;
|
||||
install: () => Promise<void>;
|
||||
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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue