From 3625301301a668814e61d93c0c9e984b0bd54e9e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Dec 2025 12:18:16 +0000 Subject: [PATCH 1/4] Add reactive state management design doc Document the plan to unify disk state, React state, and XState machine into a single Zustand store with automatic Electron IPC sync. --- docs/roadmap/reactive-state-management.md | 471 ++++++++++++++++++++++ 1 file changed, 471 insertions(+) create mode 100644 docs/roadmap/reactive-state-management.md diff --git a/docs/roadmap/reactive-state-management.md b/docs/roadmap/reactive-state-management.md new file mode 100644 index 0000000..dc8ef35 --- /dev/null +++ b/docs/roadmap/reactive-state-management.md @@ -0,0 +1,471 @@ +# Reactive State Management + +Unify disk state, React state, and state machine into a single reactive store to prevent synchronization bugs. + +## Problem + +The app currently has three separate state management systems that must stay synchronized: + +### 1. Disk State (Main Process) + +Persisted to `~/.localmost/`: +- `config.yaml` — App settings, auth tokens, preferences +- `job-history.json` — Historical job records +- `policies/*.json` — Per-repo sandbox policy cache + +**Issues:** +- Synchronous `writeFileSync` in hot paths blocks the event loop +- No atomicity — crash during write corrupts files +- No file watching — external changes are ignored +- Silent failures on write can leave disk and memory out of sync + +### 2. React State (Renderer Process) + +Three Context providers manage UI state: +- `AppConfigContext` — Theme, logs, settings, power, notifications +- `RunnerContext` — Auth, repos, runner status, jobs, targets +- `UpdateContext` — Update status, auto-check settings + +**Issues:** +- Optimistic updates persist asynchronously and may silently fail +- No rollback mechanism when disk writes fail +- Multiple contexts managing related state independently +- Manual IPC subscriptions for each state slice + +### 3. XState Machine (Main Process) + +`runner-state-machine.ts` manages runner lifecycle: +``` +idle → starting → running (listening/busy/paused) → shuttingDown → idle +``` + +**Issues:** +- Machine state is ephemeral — lost on restart +- React components subscribe via separate IPC channel +- No unified view of machine state + app state + +### The Sync Problem + +``` +┌─────────────────────────────────────────────────────────────┐ +│ MAIN PROCESS │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ Disk State │ │ XState │ │ App State │ │ +│ │ (config, │◄──?│ Machine │◄──?│ (runtime) │ │ +│ │ history) │ │ │ │ │ │ +│ └────────────┘ └────────────┘ └────────────┘ │ +│ ▲ │ │ │ +│ │ │ IPC │ IPC │ +│ │ (manual) (manual) │ +│ │ ▼ ▼ │ +├─────────┼───────────────────────────────────────────────────┤ +│ │ RENDERER PROCESS │ +│ │ ┌────────────────────────────────┐ │ +│ │ │ 3 separate Context providers │ │ +│ │ │ (may diverge from each other) │ │ +│ └────│ │ │ +│ └────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +When these systems diverge, users see stale data, settings that don't persist, or UI that doesn't reflect reality. + +## Solution + +Replace the three systems with a single Zustand store in the main process that: +1. Persists to disk via middleware +2. Embeds the XState machine via middleware +3. Syncs to renderer via Electron bridge library + +### Why Zustand + +| Requirement | Zustand | Jotai | Pure XState | +|-------------|---------|-------|-------------| +| Keep existing XState machine | ✅ via middleware | ✅ via atomWithMachine | ❌ replace | +| Electron IPC sync | ✅ Zutron/zubridge | ⚠️ custom needed | ⚠️ custom needed | +| Disk persistence | ✅ built-in middleware | ⚠️ custom needed | ⚠️ getPersistedSnapshot | +| Simple mental model | ✅ "store + selectors" | ⚠️ atoms/graphs | ⚠️ statecharts | +| Gradual migration | ✅ run alongside Context | ⚠️ different paradigm | ❌ all or nothing | + +### Target Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ MAIN PROCESS │ +│ │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ Zustand Store (source of truth) │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ persist middleware ←→ config.yaml │ │ │ +│ │ │ ←→ job-history.json │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ xstate middleware ←→ runnerMachine │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ State slices: │ │ +│ │ config: { theme, logLevel, ... } │ │ +│ │ auth: { user, tokens, ... } │ │ +│ │ runner: { status, instances, ... } ← from XState │ │ +│ │ jobs: { history, current, ... } │ │ +│ │ targets: { configs, status, ... } │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────┘ │ +│ │ │ +│ Zutron / zubridge │ +│ (automatic sync) │ +│ │ │ +├────────────────────────────┼────────────────────────────────┤ +│ RENDERER PROCESS │ +│ │ │ +│ ┌─────────────────────────┼─────────────────────────────┐ │ +│ │ Synchronized Store (read + dispatch) │ │ +│ │ │ │ +│ │ const theme = useStore(s => s.config.theme) │ │ +│ │ const status = useStore(s => s.runner.status) │ │ +│ │ dispatch({ type: 'setTheme', payload: 'dark' }) │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Key Design Decisions + +### 1. Main process owns the store + +The main process is the single source of truth. Renderer processes get synchronized read-only copies with the ability to dispatch actions. + +**Rationale:** Disk I/O and XState machine must live in main process. Having main own the store means no ambiguity about which process is authoritative. + +### 2. Keep the existing XState machine + +The `runner-state-machine.ts` is well-tested (100+ tests) and models complex state correctly. We embed it in Zustand via `zustand-middleware-xstate` rather than rewriting. + +```typescript +import { xstate } from 'zustand-middleware-xstate'; +import { runnerMachine } from './runner-state-machine'; + +const useStore = create( + xstate(runnerMachine, { + // Map machine context to store state + select: (state) => ({ + runner: { + status: selectRunnerStatus(state), + instances: state.context.instances, + currentJob: state.context.currentJob, + } + }) + }) +); +``` + +### 3. Custom YAML storage adapter + +The default Zustand persist middleware uses JSON. We need a custom storage adapter for `config.yaml`: + +```typescript +const yamlStorage: StateStorage = { + getItem: (name) => { + const path = getConfigPath(); + if (!fs.existsSync(path)) return null; + const yaml = fs.readFileSync(path, 'utf-8'); + return YAML.stringify(YAML.parse(yaml)); + }, + setItem: (name, value) => { + const path = getConfigPath(); + const data = JSON.parse(value); + // Atomic write: write to temp, then rename + const temp = `${path}.tmp`; + fs.writeFileSync(temp, YAML.stringify(data)); + fs.renameSync(temp, path); + }, + removeItem: (name) => { + fs.unlinkSync(getConfigPath()); + } +}; +``` + +### 4. Selective persistence + +Not all state should persist to disk: + +| State | Persist | Reason | +|-------|---------|--------| +| `config.theme` | ✅ | User preference | +| `config.logLevel` | ✅ | User preference | +| `auth.tokens` | ✅ (encrypted) | Survive restart | +| `runner.status` | ❌ | Derived from machine | +| `runner.instances` | ❌ | Runtime only | +| `jobs.current` | ❌ | Runtime only | +| `jobs.history` | ✅ | Separate file | + +Use Zustand's `partialize` option: + +```typescript +persist(storeCreator, { + partialize: (state) => ({ + config: state.config, + auth: { + user: state.auth.user, + // tokens handled separately with encryption + } + }) +}) +``` + +### 5. Async persistence with debouncing + +Replace synchronous writes with debounced async writes: + +```typescript +const debouncedPersist = debounce(async (state) => { + try { + await writeAtomically(getConfigPath(), YAML.stringify(state)); + } catch (err) { + // Emit error to store for UI to display + useStore.setState({ lastPersistError: err }); + } +}, 500); +``` + +### 6. Electron bridge selection + +Two main options: + +| Library | Approach | Pros | Cons | +|---------|----------|------|------| +| [Zutron](https://github.com/goosewobbler/zutron) | Main → renderer sync | Simple setup, one-way | Actions need manual IPC | +| [zubridge](https://www.npmjs.com/package/@zubridge/electron) | Bidirectional bridge | Full Zustand API in renderer | More complex setup | + +**Recommendation:** Start with Zutron for simplicity. If we need renderer-initiated actions, evaluate zubridge. + +## Migration Plan + +### Phase 1: Create Store (No Behavior Change) + +1. Create `src/main/store/index.ts` with Zustand store +2. Mirror current state shape from Context providers +3. Add persist middleware writing to new file (`config-v2.yaml`) +4. Run both systems in parallel, log any divergence + +### Phase 2: Migrate AppConfigContext + +1. Replace `AppConfigContext` state with Zustand selectors +2. Keep Context wrapper for API compatibility +3. Remove manual IPC subscriptions for settings +4. Verify persistence works correctly + +### Phase 3: Migrate RunnerContext + +1. Integrate XState machine via middleware +2. Replace `RunnerContext` state with selectors +3. Remove manual `onStatusUpdate` IPC handling +4. Verify all runner states propagate correctly + +### Phase 4: Migrate UpdateContext + +1. Move update state to store +2. Remove `UpdateContext` provider +3. Simplify update notification component + +### Phase 5: Cleanup + +1. Remove old Context providers +2. Remove manual IPC subscription code +3. Migrate `config.yaml` → schema with version field +4. Add config migration from v1 → v2 + +## Store Shape + +```typescript +interface AppStore { + // Config (persisted to config.yaml) + config: { + theme: 'light' | 'dark' | 'system'; + logLevel: LogLevel; + runnerLogLevel: LogLevel; + maxLogScrollback: number; + maxJobHistory: number; + sleepProtection: SleepProtection; + sleepProtectionConsented: boolean; + preserveWorkDir: boolean; + toolCacheLocation: string; + userFilter: UserFilterConfig; + power: PowerConfig; + notifications: NotificationConfig; + launchAtLogin: boolean; + hideOnStart: boolean; + }; + + // Auth (tokens encrypted, persisted separately) + auth: { + user: GitHubUser | null; + status: AuthStatus; + deviceCode: DeviceCodeInfo | null; + }; + + // Runner (from XState machine, not persisted) + runner: { + status: RunnerStatus; + startedAt: string | null; + error: string | null; + isPaused: boolean; + pauseReason: string | null; + instances: Map; + busyInstances: Set; + currentJob: JobInfo | null; + }; + + // Jobs (history persisted to job-history.json) + jobs: { + history: JobHistoryEntry[]; + current: JobHistoryEntry | null; + }; + + // Targets (config persisted, status not) + targets: { + configs: TargetConfig[]; + status: Map; + }; + + // Download state (not persisted) + download: { + status: DownloadStatus; + progress: DownloadProgress | null; + availableVersions: string[]; + selectedVersion: string; + installedVersion: string | null; + }; + + // Update state (not persisted) + update: { + status: UpdateStatus; + autoCheck: boolean; + checkInterval: number; + isChecking: boolean; + isDismissed: boolean; + lastChecked: string | null; + }; + + // UI state (not persisted) + ui: { + isOnline: boolean; + logs: LogEntry[]; + lastPersistError: Error | null; + }; + + // Actions + actions: { + setTheme: (theme: Theme) => void; + setLogLevel: (level: LogLevel) => void; + // ... other actions + sendRunnerEvent: (event: RunnerEvent) => void; + }; +} +``` + +## Edge Cases + +### App crashes during write + +**Current:** File may be corrupted (partial write). + +**Solution:** Atomic writes via temp file + rename: +```typescript +const temp = `${path}.tmp`; +fs.writeFileSync(temp, content); +fs.renameSync(temp, path); // Atomic on POSIX +``` + +### Renderer starts before main store ready + +**Current:** N/A (IPC waits for response). + +**Solution:** Zutron/zubridge handle this — renderer store stays empty until first sync. + +### Multiple windows + +**Current:** Each window has own Context state, synced via IPC. + +**Solution:** Zutron syncs all renderer windows from single main store automatically. + +### Store version mismatch after update + +**Solution:** Add schema version and migration: +```typescript +persist(store, { + version: 2, + migrate: (persisted, version) => { + if (version === 1) { + // Migrate from v1 schema + return migrateV1ToV2(persisted); + } + return persisted; + } +}) +``` + +### Encryption key unavailable + +**Current:** `encryptValue()` throws, save fails entirely. + +**Solution:** Separate auth token persistence with graceful degradation: +```typescript +try { + await persistEncryptedTokens(tokens); +} catch (err) { + // Store in memory only, warn user + store.setState({ + auth: { ...auth, persistenceWarning: 'Tokens not saved' } + }); +} +``` + +### XState machine reset on restart + +**Current:** Machine starts in `idle`, job history persists separately. + +**Solution:** Optionally persist machine snapshot: +```typescript +// On shutdown +const snapshot = actor.getPersistedSnapshot(); +await persistSnapshot(snapshot); + +// On startup +const snapshot = await loadSnapshot(); +createActor(machine, { snapshot }).start(); +``` + +**Trade-off:** May not want to restore mid-job state after crash. Could selectively restore only certain states (e.g., `paused` but not `busy`). + +## Out of Scope + +- **Redux DevTools integration** — Nice to have, not essential +- **Undo/redo** — Not needed for this app +- **Offline-first sync** — Single-machine app, not distributed +- **Server-side state** — All state is local + +## Open Questions + +1. **Zutron vs zubridge?** Zutron is simpler but one-way. Do we need renderer-initiated actions beyond what IPC handlers already provide? + +2. **Persist XState snapshot?** Restoring runner state after crash could be confusing if the job is gone. Maybe only persist `userPaused` flag? + +3. **Config file format?** Keep YAML for human readability, or switch to JSON for simplicity? YAML allows comments which users appreciate. + +4. **Job history location?** Keep separate `job-history.json` or merge into main config? Separate file means less frequent config writes. + +## Dependencies + +- [zustand](https://github.com/pmndrs/zustand) — Core store (~3KB) +- [zustand-middleware-xstate](https://github.com/biowaffeln/zustand-middleware-xstate) — XState integration (~1KB) +- [zutron](https://github.com/goosewobbler/zutron) or [@zubridge/electron](https://www.npmjs.com/package/@zubridge/electron) — Electron sync + +## Success Criteria + +1. Single source of truth: All state flows from main process Zustand store +2. No manual IPC subscriptions for state updates in renderer +3. Atomic disk writes: No corrupted config on crash +4. XState machine state visible alongside app state in one place +5. Gradual migration: Can run old and new systems in parallel during transition From f9d6fb6c6aae9f3fbb9a0e5e20f8b4ad7370783c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Dec 2025 12:34:06 +0000 Subject: [PATCH 2/4] Add Zustand store infrastructure for reactive state management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the foundation for unifying disk state, React state, and XState into a single reactive store as described in docs/roadmap/reactive-state-management.md. Key additions: - Zustand store with typed slices for config, auth, runner, jobs, github, update, and UI state - YAML persistence middleware with debounced atomic writes - XState synchronization middleware to sync runner machine state to store - Zubridge integration for automatic main→renderer state sync - Renderer hooks (useStore, useDispatch, convenience hooks for each slice) The store infrastructure runs alongside the existing Context providers. Next step is to migrate each Context to use the Zustand store as its backing state, eventually removing the Contexts entirely. Dependencies added: - zustand: Core state management - @zubridge/electron: Electron IPC state synchronization --- package-lock.json | 324 +++++++++++++++----- package.json | 4 +- src/main/index.ts | 18 ++ src/main/preload.ts | 7 + src/main/store/bridge.ts | 58 ++++ src/main/store/index.ts | 364 +++++++++++++++++++++++ src/main/store/init.ts | 63 ++++ src/main/store/middleware/persist.ts | 290 ++++++++++++++++++ src/main/store/middleware/xstate-sync.ts | 36 +++ src/main/store/types.ts | 346 +++++++++++++++++++++ src/renderer/store/index.ts | 106 +++++++ 11 files changed, 1535 insertions(+), 81 deletions(-) create mode 100644 src/main/store/bridge.ts create mode 100644 src/main/store/index.ts create mode 100644 src/main/store/init.ts create mode 100644 src/main/store/middleware/persist.ts create mode 100644 src/main/store/middleware/xstate-sync.ts create mode 100644 src/main/store/types.ts create mode 100644 src/renderer/store/index.ts diff --git a/package-lock.json b/package-lock.json index 8ac8a6c..1b09f8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,14 @@ "@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/react-fontawesome": "^3.1.1", + "@zubridge/electron": "^2.1.1", "is-camera-on": "^4.0.0", "js-yaml": "^4.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", "tar": "^7.5.2", - "xstate": "^5.25.0" + "xstate": "^5.25.0", + "zustand": "^5.0.9" }, "bin": { "localmost": "dist/cli.js" @@ -3651,7 +3653,6 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -3696,7 +3697,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", - "dev": true, "license": "MIT", "dependencies": { "defer-to-connect": "^2.0.0" @@ -3859,7 +3859,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-cache-semantics": "*", @@ -3868,6 +3867,15 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -3918,7 +3926,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", - "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { @@ -4024,12 +4031,17 @@ "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" } }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/mute-stream": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", @@ -4044,7 +4056,6 @@ "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -4054,14 +4065,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -4082,7 +4093,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -4130,7 +4140,6 @@ "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4503,6 +4512,61 @@ "dev": true, "license": "MIT" }, + "node_modules/@wdio/logger": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/@wdio/logger/-/logger-9.18.0.tgz", + "integrity": "sha512-HdzDrRs+ywAqbXGKqe1i/bLtCv47plz4TvsHFH3j729OooT5VH38ctFn5aLXgECmiAKDkmH/A6kOq2Zh5DIxww==", + "license": "MIT", + "dependencies": { + "chalk": "^5.1.2", + "loglevel": "^1.6.0", + "loglevel-plugin-prefix": "^0.8.4", + "safe-regex2": "^5.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/logger/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@wdio/logger/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@wdio/logger/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -4735,6 +4799,45 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@zubridge/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@zubridge/core/-/core-2.0.0.tgz", + "integrity": "sha512-PbHkJQ6EFWJE79/FmO3F0vbUNJMfRBybOVtEpy6xpIVDl4pdMrwHN4G0ybuRIzYO7IuzSJS9vBlzmaPGi0Uh8A==", + "license": "MIT", + "dependencies": { + "@wdio/logger": "^9.4.4", + "weald": "^1.0.6" + } + }, + "node_modules/@zubridge/electron": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@zubridge/electron/-/electron-2.1.1.tgz", + "integrity": "sha512-xu2An0+AELkdSSWwOcOtQFiaHVyveEuzXra/XE33mDuAytT9KQVF/jdzMrwOHSK0npVyqNIjxS41b3LZ6USl1w==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.12", + "@zubridge/core": "2.0.0", + "dequal": "^2.0.3", + "uuid": "^13.0.0", + "zustand": "^5.0.8" + }, + "peerDependencies": { + "electron": ">=12", + "redux": ">=4.0.0", + "zustand": ">=5.0.0" + }, + "peerDependenciesMeta": { + "electron": { + "optional": false + }, + "redux": { + "optional": true + }, + "zustand": { + "optional": false + } + } + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -5405,7 +5508,6 @@ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true, "license": "MIT", "optional": true }, @@ -5543,7 +5645,6 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -5701,7 +5802,6 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.6.0" @@ -5711,7 +5811,6 @@ "version": "7.0.4", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", - "dev": true, "license": "MIT", "dependencies": { "clone-response": "^1.0.2", @@ -5730,7 +5829,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, "license": "MIT", "dependencies": { "pump": "^3.0.0" @@ -6094,7 +6192,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", - "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^1.0.0" @@ -6442,7 +6539,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-urls": { @@ -6596,7 +6693,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -6612,7 +6708,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -6671,7 +6766,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -6681,7 +6775,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -6699,7 +6793,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -6727,7 +6821,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6757,7 +6850,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, "license": "MIT", "optional": true }, @@ -6969,7 +7061,6 @@ "version": "39.2.6", "resolved": "https://registry.npmjs.org/electron/-/electron-39.2.6.tgz", "integrity": "sha512-dHBgTodWBZd+tL1Dt0PSh/CFLHeDkFCTKCTXu1dgPhlE9Z3k2zzlBQ9B2oW55CFsKanBDHiUomHJNw0XaSdQpA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -7061,7 +7152,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", @@ -7083,7 +7173,6 @@ "version": "22.19.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -7093,7 +7182,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -7111,7 +7199,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -7126,7 +7213,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, "license": "MIT", "optionalDependencies": { "graceful-fs": "^4.1.6" @@ -7136,14 +7222,12 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/electron/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7153,7 +7237,6 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 4.0.0" @@ -7216,7 +7299,6 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -7250,7 +7332,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7359,7 +7440,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7369,7 +7450,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7474,7 +7555,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, "license": "MIT", "optional": true }, @@ -7499,7 +7579,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -8123,7 +8203,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "debug": "^4.1.1", @@ -8144,7 +8223,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -8162,7 +8240,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, "license": "MIT", "dependencies": { "pump": "^3.0.0" @@ -8178,7 +8255,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/fast-deep-equal": { @@ -8271,7 +8347,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, "license": "MIT", "dependencies": { "pend": "~1.2.0" @@ -8897,7 +8972,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "dev": true, "license": "BSD-3-Clause", "optional": true, "dependencies": { @@ -8945,7 +9019,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "define-properties": "^1.2.1", @@ -8962,7 +9036,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8975,7 +9049,6 @@ "version": "11.8.6", "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", - "dev": true, "license": "MIT", "dependencies": { "@sindresorhus/is": "^4.0.0", @@ -9001,7 +9074,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/gzip-size": { @@ -9069,7 +9141,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -9279,7 +9351,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/http-proxy-agent": { @@ -9339,7 +9410,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, "license": "MIT", "dependencies": { "quick-lru": "^5.1.1", @@ -11757,7 +11827,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, "license": "MIT" }, "node_modules/json-parse-even-better-errors": { @@ -11786,7 +11855,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, "license": "ISC", "optional": true }, @@ -11844,7 +11912,6 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" @@ -12204,6 +12271,25 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/loglevel-plugin-prefix": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/loglevel-plugin-prefix/-/loglevel-plugin-prefix-0.8.4.tgz", + "integrity": "sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -12230,7 +12316,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12417,7 +12502,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -12520,7 +12604,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -12910,7 +12993,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -12989,7 +13071,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13074,7 +13156,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -13222,7 +13303,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13502,7 +13582,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -13932,7 +14011,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -14016,7 +14094,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -14082,7 +14159,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -14467,7 +14543,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true, "license": "MIT" }, "node_modules/resolve-cwd": { @@ -14507,7 +14582,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", - "dev": true, "license": "MIT", "dependencies": { "lowercase-keys": "^2.0.0" @@ -14540,6 +14614,15 @@ "dev": true, "license": "ISC" }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -14589,7 +14672,6 @@ "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, "license": "BSD-3-Clause", "optional": true, "dependencies": { @@ -14728,6 +14810,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -14800,7 +14901,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "dev": true, "license": "MIT", "optional": true }, @@ -14808,7 +14908,6 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -14825,7 +14924,6 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, "license": "(MIT OR CC0-1.0)", "optional": true, "engines": { @@ -15428,7 +15526,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, "license": "BSD-3-Clause", "optional": true }, @@ -15758,7 +15855,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "debug": "^4.1.0" @@ -15771,7 +15867,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -15789,7 +15884,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/supports-color": { @@ -16490,7 +16584,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -16633,6 +16726,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -16726,6 +16832,37 @@ "defaults": "^1.0.3" } }, + "node_modules/weald": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/weald/-/weald-1.1.1.tgz", + "integrity": "sha512-PaEQShzMCz8J/AD2N3dJMc1hTZWkJeLKS2NMeiVkV5KDHwgZe7qXLEzyodsT/SODxWDdXJJqocuwf3kHzcXhSQ==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "ms": "^3.0.0-canary.1", + "supports-color": "^10.0.0" + } + }, + "node_modules/weald/node_modules/ms": { + "version": "3.0.0-canary.202508261828", + "resolved": "https://registry.npmjs.org/ms/-/ms-3.0.0-canary.202508261828.tgz", + "integrity": "sha512-NotsCoUCIUkojWCzQff4ttdCfIPoA1UGZsyQbi7KmqkNRfKCrvga8JJi2PknHymHOuor0cJSn/ylj52Cbt2IrQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/weald/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -17134,7 +17271,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -17310,7 +17446,6 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", @@ -17377,6 +17512,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 112181b..f0b0756 100644 --- a/package.json +++ b/package.json @@ -83,12 +83,14 @@ "@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/react-fontawesome": "^3.1.1", + "@zubridge/electron": "^2.1.1", "is-camera-on": "^4.0.0", "js-yaml": "^4.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", "tar": "^7.5.2", - "xstate": "^5.25.0" + "xstate": "^5.25.0", + "zustand": "^5.0.9" }, "overrides": { "tmp": "^0.2.5" diff --git a/src/main/index.ts b/src/main/index.ts index e40f379..03d0b85 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -96,6 +96,9 @@ import { selectEffectivePauseState, } from './runner-state-service'; +// Zustand store +import { initStore, connectWindow, cleanupStore } from './store/init'; + // ============================================================================ // App Initialization // ============================================================================ @@ -165,6 +168,9 @@ app.whenReady().then(async () => { // Initialize state machine (must be early - before anything uses state) initRunnerStateMachine(); + // Initialize Zustand store (after state machine, so XState sync works) + initStore(); + // Subscribe to state changes for UI updates onStateChange((snapshot) => { const mainWindow = getMainWindow(); @@ -429,6 +435,12 @@ app.whenReady().then(async () => { setDockIcon(); setupIpcHandlers(); + // Connect window to Zustand store via zubridge + const newMainWindow = getMainWindow(); + if (newMainWindow) { + connectWindow(newMainWindow); + } + // Initialize auto-updater const mainWindow = getMainWindow(); if (mainWindow) { @@ -642,6 +654,9 @@ app.on('before-quit', async (event) => { sendRunnerEvent({ type: 'SHUTDOWN_COMPLETE' }); stopRunnerStateMachine(); + // Clean up Zustand store (flushes persistence) + cleanupStore(); + logger?.info('Exiting'); app.quit(); } @@ -695,6 +710,9 @@ process.on('SIGINT', async () => { sendRunnerEvent({ type: 'SHUTDOWN_COMPLETE' }); stopRunnerStateMachine(); + // Clean up Zustand store (flushes persistence) + cleanupStore(); + getLogger()?.info('Exiting'); app.quit(); }); diff --git a/src/main/preload.ts b/src/main/preload.ts index e0fb2c7..b1e8864 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer } from 'electron'; +import { preloadBridge } from '@zubridge/electron/preload'; import { IPC_CHANNELS, RunnerState, @@ -21,6 +22,12 @@ import { ResourcePauseState, } from '../shared/types'; +// Initialize zubridge preload handlers +const { handlers: zubridgeHandlers } = preloadBridge(); + +// Expose zubridge to renderer +contextBridge.exposeInMainWorld('zubridge', zubridgeHandlers); + // Expose protected methods to the renderer process contextBridge.exposeInMainWorld('localmost', { // App / Setup diff --git a/src/main/store/bridge.ts b/src/main/store/bridge.ts new file mode 100644 index 0000000..43c93ab --- /dev/null +++ b/src/main/store/bridge.ts @@ -0,0 +1,58 @@ +/** + * Zubridge integration - syncs Zustand store to renderer processes. + */ + +import { BrowserWindow } from 'electron'; +import { createZustandBridge } from '@zubridge/electron/main'; +import { store } from './index'; + +// Bridge instance +let bridge: ReturnType | null = null; +let unsubscribe: (() => void) | null = null; + +/** + * Initialize the zubridge for a window. + * Call this after creating the main window. + */ +export function initBridge(mainWindow: BrowserWindow): void { + if (bridge) { + // Already initialized, just subscribe the new window + const sub = bridge.subscribe([mainWindow]); + // Store the unsubscribe function + if (unsubscribe) { + const oldUnsub = unsubscribe; + unsubscribe = () => { + oldUnsub(); + sub.unsubscribe(); + }; + } else { + unsubscribe = sub.unsubscribe; + } + return; + } + + // Create the bridge + bridge = createZustandBridge(store); + + // Subscribe the window + const sub = bridge.subscribe([mainWindow]); + unsubscribe = sub.unsubscribe; +} + +/** + * Clean up the bridge when the app is quitting. + */ +export function destroyBridge(): void { + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + bridge = null; +} + +/** + * Get the bridge instance (for advanced use cases). + */ +export function getBridge() { + return bridge; +} diff --git a/src/main/store/index.ts b/src/main/store/index.ts new file mode 100644 index 0000000..e68ba46 --- /dev/null +++ b/src/main/store/index.ts @@ -0,0 +1,364 @@ +/** + * Main Zustand store - single source of truth for app state. + * + * This store runs in the main process and syncs to renderer via zubridge. + */ + +import { createStore } from 'zustand/vanilla'; +import { subscribeWithSelector } from 'zustand/middleware'; +import { + AppStore, + AppState, + defaultAppState, + ConfigSlice, + ThemeSetting, +} from './types'; +import { + LogLevel, + SleepProtection, + ToolCacheLocation, + UserFilterConfig, + PowerConfig, + NotificationsConfig, + GitHubUser, + DeviceCodeInfo, + RunnerState, + JobHistoryEntry, + DownloadProgress, + RunnerRelease, + Target, + RunnerProxyStatus, + UpdateStatus, + UpdateSettings, + LogEntry, + GitHubRepo, + GitHubOrg, +} from '../../shared/types'; + +// Create the store +export const store = createStore()( + subscribeWithSelector((set, get) => ({ + // Initial state + ...defaultAppState, + + // ========================================================================== + // Config Actions + // ========================================================================== + + setTheme: (theme: ThemeSetting) => { + set((state) => ({ config: { ...state.config, theme } })); + }, + + setLogLevel: (logLevel: LogLevel) => { + set((state) => ({ config: { ...state.config, logLevel } })); + }, + + setRunnerLogLevel: (runnerLogLevel: LogLevel) => { + set((state) => ({ config: { ...state.config, runnerLogLevel } })); + }, + + setMaxLogScrollback: (maxLogScrollback: number) => { + set((state) => { + // Trim logs if needed + const logs = state.ui.logs.length > maxLogScrollback + ? state.ui.logs.slice(-maxLogScrollback) + : state.ui.logs; + return { + config: { ...state.config, maxLogScrollback }, + ui: { ...state.ui, logs }, + }; + }); + }, + + setMaxJobHistory: (maxJobHistory: number) => { + set((state) => ({ config: { ...state.config, maxJobHistory } })); + }, + + setSleepProtection: (sleepProtection: SleepProtection) => { + set((state) => ({ config: { ...state.config, sleepProtection } })); + }, + + consentToSleepProtection: () => { + set((state) => ({ config: { ...state.config, sleepProtectionConsented: true } })); + }, + + setPreserveWorkDir: (preserveWorkDir: 'never' | 'session' | 'always') => { + set((state) => ({ config: { ...state.config, preserveWorkDir } })); + }, + + setToolCacheLocation: (toolCacheLocation: ToolCacheLocation) => { + set((state) => ({ config: { ...state.config, toolCacheLocation } })); + }, + + setUserFilter: (userFilter: UserFilterConfig) => { + set((state) => ({ config: { ...state.config, userFilter } })); + }, + + setPower: (power: PowerConfig) => { + set((state) => ({ config: { ...state.config, power } })); + }, + + setNotifications: (notifications: NotificationsConfig) => { + set((state) => ({ config: { ...state.config, notifications } })); + }, + + setLaunchAtLogin: (launchAtLogin: boolean) => { + set((state) => ({ config: { ...state.config, launchAtLogin } })); + }, + + setHideOnStart: (hideOnStart: boolean) => { + set((state) => ({ config: { ...state.config, hideOnStart } })); + }, + + updateRunnerConfig: (updates: Partial) => { + set((state) => ({ + config: { + ...state.config, + runnerConfig: { ...state.config.runnerConfig, ...updates }, + }, + })); + }, + + setTargets: (targets: Target[]) => { + set((state) => ({ config: { ...state.config, targets } })); + }, + + setMaxConcurrentJobs: (maxConcurrentJobs: number) => { + set((state) => ({ config: { ...state.config, maxConcurrentJobs } })); + }, + + // ========================================================================== + // Auth Actions + // ========================================================================== + + setUser: (user: GitHubUser | null) => { + set((state) => ({ + auth: { + ...state.auth, + user, + isAuthenticated: user !== null, + }, + })); + }, + + setIsAuthenticating: (isAuthenticating: boolean) => { + set((state) => ({ auth: { ...state.auth, isAuthenticating } })); + }, + + setDeviceCode: (deviceCode: DeviceCodeInfo | null) => { + set((state) => ({ auth: { ...state.auth, deviceCode } })); + }, + + logout: () => { + set((state) => ({ + auth: { + ...state.auth, + user: null, + isAuthenticated: false, + deviceCode: null, + }, + github: { + repos: [], + orgs: [], + }, + })); + }, + + // ========================================================================== + // Runner Actions + // ========================================================================== + + setRunnerState: (runnerState: RunnerState) => { + set((state) => ({ runner: { ...state.runner, runnerState } })); + }, + + setIsDownloaded: (isDownloaded: boolean) => { + set((state) => ({ runner: { ...state.runner, isDownloaded } })); + }, + + setIsConfigured: (isConfigured: boolean) => { + set((state) => ({ runner: { ...state.runner, isConfigured } })); + }, + + setRunnerVersion: (runnerVersion: { version: string | null; url: string | null }) => { + set((state) => ({ runner: { ...state.runner, runnerVersion } })); + }, + + setAvailableVersions: (availableVersions: RunnerRelease[]) => { + set((state) => ({ runner: { ...state.runner, availableVersions } })); + }, + + setSelectedVersion: (selectedVersion: string) => { + set((state) => ({ runner: { ...state.runner, selectedVersion } })); + }, + + setDownloadProgress: (downloadProgress: DownloadProgress | null) => { + set((state) => ({ runner: { ...state.runner, downloadProgress } })); + }, + + setIsLoadingVersions: (isLoadingVersions: boolean) => { + set((state) => ({ runner: { ...state.runner, isLoadingVersions } })); + }, + + setRunnerDisplayName: (runnerDisplayName: string | null) => { + set((state) => ({ runner: { ...state.runner, runnerDisplayName } })); + }, + + setTargetStatus: (targetStatus: RunnerProxyStatus[]) => { + set((state) => ({ runner: { ...state.runner, targetStatus } })); + }, + + // ========================================================================== + // Jobs Actions + // ========================================================================== + + setJobHistory: (history: JobHistoryEntry[]) => { + set((state) => ({ jobs: { ...state.jobs, history } })); + }, + + addJob: (job: JobHistoryEntry) => { + set((state) => { + const maxHistory = state.config.maxJobHistory; + const history = [job, ...state.jobs.history].slice(0, maxHistory); + return { jobs: { ...state.jobs, history } }; + }); + }, + + updateJob: (jobId: string, updates: Partial) => { + set((state) => ({ + jobs: { + ...state.jobs, + history: state.jobs.history.map((job) => + job.id === jobId ? { ...job, ...updates } : job + ), + }, + })); + }, + + // ========================================================================== + // GitHub Actions + // ========================================================================== + + setRepos: (repos: GitHubRepo[]) => { + set((state) => ({ github: { ...state.github, repos } })); + }, + + setOrgs: (orgs: GitHubOrg[]) => { + set((state) => ({ github: { ...state.github, orgs } })); + }, + + // ========================================================================== + // Update Actions + // ========================================================================== + + setUpdateStatus: (status: UpdateStatus) => { + set((state) => ({ + update: { + ...state.update, + status, + isChecking: status.status === 'checking', + // Reset dismissed when new update is available + isDismissed: status.status === 'available' ? false : state.update.isDismissed, + }, + })); + }, + + setUpdateSettings: (settings: UpdateSettings) => { + set((state) => ({ update: { ...state.update, settings } })); + }, + + setIsChecking: (isChecking: boolean) => { + set((state) => ({ update: { ...state.update, isChecking } })); + }, + + setIsDismissed: (isDismissed: boolean) => { + set((state) => ({ update: { ...state.update, isDismissed } })); + }, + + setLastChecked: (lastChecked: string | null) => { + set((state) => ({ update: { ...state.update, lastChecked } })); + }, + + // ========================================================================== + // UI Actions + // ========================================================================== + + setIsOnline: (isOnline: boolean) => { + set((state) => ({ ui: { ...state.ui, isOnline } })); + }, + + setIsLoading: (isLoading: boolean) => { + set((state) => ({ ui: { ...state.ui, isLoading } })); + }, + + setIsInitialLoading: (isInitialLoading: boolean) => { + set((state) => ({ ui: { ...state.ui, isInitialLoading } })); + }, + + setError: (error: string | null) => { + set((state) => ({ ui: { ...state.ui, error } })); + }, + + addLog: (log: LogEntry) => { + set((state) => { + const maxScrollback = state.config.maxLogScrollback; + const logs = [...state.ui.logs.slice(-(maxScrollback - 1)), log]; + return { ui: { ...state.ui, logs } }; + }); + }, + + clearLogs: () => { + set((state) => ({ ui: { ...state.ui, logs: [] } })); + }, + })) +); + +// Export typed getState and subscribe +export const getState = store.getState; +export const setState = store.setState; +export const subscribe = store.subscribe; + +// ============================================================================= +// Selectors +// ============================================================================= + +// Config selectors +export const selectConfig = (state: AppState) => state.config; +export const selectTheme = (state: AppState) => state.config.theme; +export const selectLogLevel = (state: AppState) => state.config.logLevel; +export const selectRunnerLogLevel = (state: AppState) => state.config.runnerLogLevel; +export const selectPower = (state: AppState) => state.config.power; +export const selectNotifications = (state: AppState) => state.config.notifications; +export const selectTargets = (state: AppState) => state.config.targets; +export const selectRunnerConfig = (state: AppState) => state.config.runnerConfig; + +// Auth selectors +export const selectAuth = (state: AppState) => state.auth; +export const selectUser = (state: AppState) => state.auth.user; +export const selectIsAuthenticated = (state: AppState) => state.auth.isAuthenticated; + +// Runner selectors +export const selectRunner = (state: AppState) => state.runner; +export const selectRunnerState = (state: AppState) => state.runner.runnerState; +export const selectIsDownloaded = (state: AppState) => state.runner.isDownloaded; +export const selectIsConfigured = (state: AppState) => state.runner.isConfigured; +export const selectTargetStatus = (state: AppState) => state.runner.targetStatus; + +// Jobs selectors +export const selectJobs = (state: AppState) => state.jobs; +export const selectJobHistory = (state: AppState) => state.jobs.history; + +// GitHub selectors +export const selectGitHub = (state: AppState) => state.github; +export const selectRepos = (state: AppState) => state.github.repos; +export const selectOrgs = (state: AppState) => state.github.orgs; + +// Update selectors +export const selectUpdate = (state: AppState) => state.update; +export const selectUpdateStatus = (state: AppState) => state.update.status; + +// UI selectors +export const selectUI = (state: AppState) => state.ui; +export const selectIsOnline = (state: AppState) => state.ui.isOnline; +export const selectLogs = (state: AppState) => state.ui.logs; +export const selectError = (state: AppState) => state.ui.error; diff --git a/src/main/store/init.ts b/src/main/store/init.ts new file mode 100644 index 0000000..584a7cf --- /dev/null +++ b/src/main/store/init.ts @@ -0,0 +1,63 @@ +/** + * Store initialization - call this during app startup. + * + * Sets up: + * - YAML persistence (load config from disk, save on changes) + * - XState synchronization (sync runner machine to store) + * - Zubridge (sync store to renderer) + */ + +import { BrowserWindow } from 'electron'; +import { setupPersistence, flushPersistence } from './middleware/persist'; +import { setupXStateSync } from './middleware/xstate-sync'; +import { initBridge, destroyBridge } from './bridge'; + +// Cleanup functions +let cleanupFns: (() => void)[] = []; + +/** + * Initialize the store and all middleware. + * Call this after the runner state machine is initialized. + */ +export function initStore(): void { + // Set up persistence (loads config from disk) + const unsubPersist = setupPersistence(); + cleanupFns.push(unsubPersist); + + // Set up XState sync (syncs runner machine to store) + const unsubXState = setupXStateSync(); + cleanupFns.push(unsubXState); +} + +/** + * Connect a window to the store via zubridge. + * Call this after creating each BrowserWindow. + */ +export function connectWindow(window: BrowserWindow): void { + initBridge(window); +} + +/** + * Clean up the store before app quit. + */ +export function cleanupStore(): void { + // Flush any pending persistence + flushPersistence(); + + // Run all cleanup functions + for (const cleanup of cleanupFns) { + try { + cleanup(); + } catch (e) { + console.error('Store cleanup error:', e); + } + } + cleanupFns = []; + + // Destroy the bridge + destroyBridge(); +} + +// Re-export store for convenience +export { store, getState, setState, subscribe } from './index'; +export * from './types'; diff --git a/src/main/store/middleware/persist.ts b/src/main/store/middleware/persist.ts new file mode 100644 index 0000000..eec45b9 --- /dev/null +++ b/src/main/store/middleware/persist.ts @@ -0,0 +1,290 @@ +/** + * YAML persistence middleware for Zustand store. + * + * Handles loading config from disk on startup and saving changes with debouncing. + * Uses atomic writes (temp file + rename) to prevent corruption. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import { getAppDataDir, getConfigPath } from '../../paths'; +import { encryptValue, decryptValue } from '../../encryption'; +import { bootLog } from '../../log-file'; +import { store, getState } from '../index'; +import { ConfigSlice, defaultConfigState } from '../types'; +import { AppConfig } from '../../config'; + +// Debounce timer for persistence +let persistTimer: NodeJS.Timeout | null = null; +const PERSIST_DEBOUNCE_MS = 500; + +// Track if we're currently loading to avoid save loops +let isLoading = false; + +/** + * Keys from ConfigSlice that should be persisted to disk. + * Auth tokens are handled separately with encryption. + */ +const PERSISTED_CONFIG_KEYS: (keyof ConfigSlice)[] = [ + 'theme', + 'logLevel', + 'runnerLogLevel', + 'maxLogScrollback', + 'maxJobHistory', + 'sleepProtection', + 'sleepProtectionConsented', + 'preserveWorkDir', + 'toolCacheLocation', + 'userFilter', + 'power', + 'notifications', + 'launchAtLogin', + 'hideOnStart', + 'runnerConfig', + 'targets', + 'maxConcurrentJobs', +]; + +/** + * Load persisted config from YAML file into the store. + */ +export function loadPersistedConfig(): void { + isLoading = true; + + try { + const configPath = getConfigPath(); + const configDir = getAppDataDir(); + + // Check for old JSON config and migrate + const oldJsonPath = path.join(configDir, 'config.json'); + if (fs.existsSync(oldJsonPath) && !fs.existsSync(configPath)) { + try { + const jsonContent = fs.readFileSync(oldJsonPath, 'utf-8'); + const config = JSON.parse(jsonContent) as AppConfig; + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, yaml.dump(config, { indent: 2, lineWidth: -1 })); + fs.unlinkSync(oldJsonPath); + bootLog('info', 'Migrated config from JSON to YAML'); + } catch (e) { + bootLog('warn', `Failed to migrate JSON config: ${(e as Error).message}`); + } + } + + // Load from YAML config file + if (!fs.existsSync(configPath)) { + bootLog('info', 'No config file found, using defaults'); + isLoading = false; + return; + } + + const yamlContent = fs.readFileSync(configPath, 'utf-8'); + const diskConfig = (yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as AppConfig) || {}; + + // Map disk config to store state + const configUpdates: Partial = {}; + + // Theme + if (diskConfig.theme && ['light', 'dark', 'auto'].includes(diskConfig.theme)) { + configUpdates.theme = diskConfig.theme as ConfigSlice['theme']; + } + + // Log levels + if (diskConfig.logLevel && ['debug', 'info', 'warn', 'error'].includes(diskConfig.logLevel)) { + configUpdates.logLevel = diskConfig.logLevel; + } + if (diskConfig.runnerLogLevel && ['debug', 'info', 'warn', 'error'].includes(diskConfig.runnerLogLevel)) { + configUpdates.runnerLogLevel = diskConfig.runnerLogLevel; + } + + // Sleep protection + if (diskConfig.sleepProtection && ['never', 'when-busy', 'always'].includes(diskConfig.sleepProtection)) { + configUpdates.sleepProtection = diskConfig.sleepProtection; + } + + // Preserve work dir + if (diskConfig.preserveWorkDir && ['never', 'always'].includes(diskConfig.preserveWorkDir)) { + configUpdates.preserveWorkDir = diskConfig.preserveWorkDir; + } + + // User filter + if (diskConfig.userFilter) { + const filter = diskConfig.userFilter; + if (filter.mode && ['everyone', 'just-me', 'allowlist'].includes(filter.mode)) { + configUpdates.userFilter = { + mode: filter.mode, + allowlist: Array.isArray(filter.allowlist) ? filter.allowlist : [], + }; + } + } + + // Power settings + if (diskConfig.power) { + configUpdates.power = { + ...defaultConfigState.power, + ...diskConfig.power, + }; + } + + // Notifications + if (diskConfig.notifications) { + configUpdates.notifications = { + ...defaultConfigState.notifications, + ...diskConfig.notifications, + }; + } + + // Runner config + if (diskConfig.runnerConfig) { + configUpdates.runnerConfig = { + ...defaultConfigState.runnerConfig, + level: diskConfig.runnerConfig.level || defaultConfigState.runnerConfig.level, + repoUrl: diskConfig.runnerConfig.repoUrl || '', + orgName: diskConfig.runnerConfig.orgName || '', + runnerName: diskConfig.runnerConfig.runnerName || '', + labels: diskConfig.runnerConfig.labels || defaultConfigState.runnerConfig.labels, + runnerCount: diskConfig.runnerConfig.runnerCount || defaultConfigState.runnerConfig.runnerCount, + }; + } + + // Targets + if (Array.isArray(diskConfig.targets)) { + configUpdates.targets = diskConfig.targets; + } + + // Max concurrent jobs + if (typeof diskConfig.maxConcurrentJobs === 'number') { + configUpdates.maxConcurrentJobs = diskConfig.maxConcurrentJobs; + } + + // Boolean flags + if (typeof diskConfig.launchAtLogin === 'boolean') { + configUpdates.launchAtLogin = diskConfig.launchAtLogin; + } + if (typeof diskConfig.hideOnStart === 'boolean') { + configUpdates.hideOnStart = diskConfig.hideOnStart; + } + + // Apply updates to store + if (Object.keys(configUpdates).length > 0) { + store.setState((state) => ({ + config: { ...state.config, ...configUpdates }, + })); + } + + // Handle auth tokens separately (with decryption) + if (diskConfig.auth) { + try { + const accessToken = decryptValue(diskConfig.auth.accessToken); + const refreshToken = diskConfig.auth.refreshToken + ? decryptValue(diskConfig.auth.refreshToken) + : undefined; + + store.setState((state) => ({ + auth: { + ...state.auth, + user: diskConfig.auth!.user, + isAuthenticated: true, + }, + })); + + // Store decrypted tokens in a separate location (not in Zustand for security) + // The auth-tokens module handles this + } catch (e) { + bootLog('warn', `Failed to decrypt auth tokens: ${(e as Error).message}`); + } + } + + bootLog('info', 'Loaded config from disk'); + } catch (e) { + bootLog('warn', `Failed to load config: ${(e as Error).message}`); + } finally { + isLoading = false; + } +} + +/** + * Save current config state to disk. + * Uses atomic write (temp file + rename) to prevent corruption. + */ +export function savePersistedConfig(): void { + if (isLoading) { + return; + } + + try { + const configPath = getConfigPath(); + const configDir = getAppDataDir(); + const state = getState(); + + // Build config object from store state + const configToSave: Record = {}; + + // Copy persisted keys + for (const key of PERSISTED_CONFIG_KEYS) { + const value = state.config[key]; + if (value !== undefined) { + configToSave[key] = value; + } + } + + // Auth is handled separately by auth-tokens module + // We don't save it here to avoid conflicts + + // Ensure directory exists + fs.mkdirSync(configDir, { recursive: true }); + + // Atomic write: write to temp file, then rename + const tempPath = `${configPath}.tmp`; + const yamlContent = yaml.dump(configToSave, { indent: 2, lineWidth: -1 }); + fs.writeFileSync(tempPath, yamlContent); + fs.renameSync(tempPath, configPath); + + bootLog('debug', 'Saved config to disk'); + } catch (e) { + bootLog('error', `Failed to save config: ${(e as Error).message}`); + } +} + +/** + * Debounced save - called when config changes. + */ +function debouncedSave(): void { + if (persistTimer) { + clearTimeout(persistTimer); + } + persistTimer = setTimeout(() => { + savePersistedConfig(); + persistTimer = null; + }, PERSIST_DEBOUNCE_MS); +} + +/** + * Subscribe to config changes and persist them. + */ +export function setupPersistence(): () => void { + // Load initial config + loadPersistedConfig(); + + // Subscribe to config changes + const unsubscribe = store.subscribe( + (state) => state.config, + () => { + debouncedSave(); + }, + { equalityFn: Object.is } + ); + + return unsubscribe; +} + +/** + * Force an immediate save (e.g., before app quit). + */ +export function flushPersistence(): void { + if (persistTimer) { + clearTimeout(persistTimer); + persistTimer = null; + } + savePersistedConfig(); +} diff --git a/src/main/store/middleware/xstate-sync.ts b/src/main/store/middleware/xstate-sync.ts new file mode 100644 index 0000000..9084d50 --- /dev/null +++ b/src/main/store/middleware/xstate-sync.ts @@ -0,0 +1,36 @@ +/** + * XState synchronization middleware for Zustand store. + * + * Subscribes to the XState runner machine and syncs its state to the Zustand store. + * This provides a unified view of all app state through Zustand while keeping + * the XState machine as the source of truth for runner lifecycle. + */ + +import { onStateChange, selectRunnerStatus, selectEffectivePauseState } from '../../runner-state-service'; +import { store } from '../index'; + +/** + * Set up synchronization between XState machine and Zustand store. + * Returns an unsubscribe function. + */ +export function setupXStateSync(): () => void { + const unsubscribe = onStateChange((snapshot) => { + // Get the runner state from the machine + const runnerState = selectRunnerStatus(snapshot); + const pauseState = selectEffectivePauseState(snapshot); + + // Update the Zustand store + store.setState((state) => ({ + runner: { + ...state.runner, + runnerState: { + ...runnerState, + // Add pause info to the runner state if paused + ...(pauseState.isPaused && { error: pauseState.reason ?? undefined }), + }, + }, + })); + }); + + return unsubscribe; +} diff --git a/src/main/store/types.ts b/src/main/store/types.ts new file mode 100644 index 0000000..35310bb --- /dev/null +++ b/src/main/store/types.ts @@ -0,0 +1,346 @@ +/** + * Zustand store types - single source of truth for app state. + */ + +import { + LogEntry, + LogLevel, + SleepProtection, + ToolCacheLocation, + UserFilterConfig, + PowerConfig, + NotificationsConfig, + GitHubUser, + GitHubRepo, + GitHubOrg, + DeviceCodeInfo, + RunnerState, + JobHistoryEntry, + DownloadProgress, + RunnerRelease, + Target, + RunnerProxyStatus, + UpdateStatus, + UpdateSettings, + DEFAULT_POWER_CONFIG, + DEFAULT_NOTIFICATIONS_CONFIG, +} from '../../shared/types'; + +// ============================================================================= +// Theme Types +// ============================================================================= + +export type ThemeSetting = 'light' | 'dark' | 'auto'; + +// ============================================================================= +// Config Slice - Settings that persist to disk +// ============================================================================= + +export interface ConfigSlice { + // Theme + theme: ThemeSetting; + + // Logging + logLevel: LogLevel; + runnerLogLevel: LogLevel; + maxLogScrollback: number; + + // Job history + maxJobHistory: number; + + // Sleep protection + sleepProtection: SleepProtection; + sleepProtectionConsented: boolean; + + // Runner settings + preserveWorkDir: 'never' | 'session' | 'always'; + toolCacheLocation: ToolCacheLocation; + + // User filter + userFilter: UserFilterConfig; + + // Power settings + power: PowerConfig; + + // Notifications + notifications: NotificationsConfig; + + // App launch settings + launchAtLogin: boolean; + hideOnStart: boolean; + + // Runner configuration + runnerConfig: { + level: 'repo' | 'org'; + repoUrl: string; + orgName: string; + runnerName: string; + labels: string; + runnerCount: number; + }; + + // Multi-target configuration + targets: Target[]; + maxConcurrentJobs: number; +} + +// ============================================================================= +// Auth Slice - Authentication state +// ============================================================================= + +export interface AuthSlice { + user: GitHubUser | null; + isAuthenticated: boolean; + isAuthenticating: boolean; + deviceCode: DeviceCodeInfo | null; +} + +// ============================================================================= +// Runner Slice - Runner runtime state (mostly from XState) +// ============================================================================= + +export interface RunnerSlice { + // Runner status (from XState machine) + runnerState: RunnerState; + + // Binary state + isDownloaded: boolean; + isConfigured: boolean; + runnerVersion: { version: string | null; url: string | null }; + availableVersions: RunnerRelease[]; + selectedVersion: string; + downloadProgress: DownloadProgress | null; + isLoadingVersions: boolean; + runnerDisplayName: string | null; + + // Target status (runtime) + targetStatus: RunnerProxyStatus[]; +} + +// ============================================================================= +// Jobs Slice - Job history +// ============================================================================= + +export interface JobsSlice { + history: JobHistoryEntry[]; +} + +// ============================================================================= +// GitHub Slice - Repos and orgs data +// ============================================================================= + +export interface GitHubSlice { + repos: GitHubRepo[]; + orgs: GitHubOrg[]; +} + +// ============================================================================= +// Update Slice - Auto-update state +// ============================================================================= + +export interface UpdateSlice { + status: UpdateStatus; + settings: UpdateSettings; + isChecking: boolean; + isDismissed: boolean; + lastChecked: string | null; +} + +// ============================================================================= +// UI Slice - Transient UI state (not persisted) +// ============================================================================= + +export interface UISlice { + isOnline: boolean; + isLoading: boolean; + isInitialLoading: boolean; + error: string | null; + logs: LogEntry[]; +} + +// ============================================================================= +// Combined Store State +// ============================================================================= + +export interface AppState { + config: ConfigSlice; + auth: AuthSlice; + runner: RunnerSlice; + jobs: JobsSlice; + github: GitHubSlice; + update: UpdateSlice; + ui: UISlice; +} + +// ============================================================================= +// Store Actions +// ============================================================================= + +export interface ConfigActions { + setTheme: (theme: ThemeSetting) => void; + setLogLevel: (level: LogLevel) => void; + setRunnerLogLevel: (level: LogLevel) => void; + setMaxLogScrollback: (max: number) => void; + setMaxJobHistory: (max: number) => void; + setSleepProtection: (setting: SleepProtection) => void; + consentToSleepProtection: () => void; + setPreserveWorkDir: (setting: 'never' | 'session' | 'always') => void; + setToolCacheLocation: (setting: ToolCacheLocation) => void; + setUserFilter: (filter: UserFilterConfig) => void; + setPower: (config: PowerConfig) => void; + setNotifications: (config: NotificationsConfig) => void; + setLaunchAtLogin: (enabled: boolean) => void; + setHideOnStart: (enabled: boolean) => void; + updateRunnerConfig: (updates: Partial) => void; + setTargets: (targets: Target[]) => void; + setMaxConcurrentJobs: (max: number) => void; +} + +export interface AuthActions { + setUser: (user: GitHubUser | null) => void; + setIsAuthenticating: (isAuthenticating: boolean) => void; + setDeviceCode: (deviceCode: DeviceCodeInfo | null) => void; + logout: () => void; +} + +export interface RunnerActions { + setRunnerState: (state: RunnerState) => void; + setIsDownloaded: (isDownloaded: boolean) => void; + setIsConfigured: (isConfigured: boolean) => void; + setRunnerVersion: (version: { version: string | null; url: string | null }) => void; + setAvailableVersions: (versions: RunnerRelease[]) => void; + setSelectedVersion: (version: string) => void; + setDownloadProgress: (progress: DownloadProgress | null) => void; + setIsLoadingVersions: (isLoading: boolean) => void; + setRunnerDisplayName: (name: string | null) => void; + setTargetStatus: (status: RunnerProxyStatus[]) => void; +} + +export interface JobsActions { + setJobHistory: (history: JobHistoryEntry[]) => void; + addJob: (job: JobHistoryEntry) => void; + updateJob: (jobId: string, updates: Partial) => void; +} + +export interface GitHubActions { + setRepos: (repos: GitHubRepo[]) => void; + setOrgs: (orgs: GitHubOrg[]) => void; +} + +export interface UpdateActions { + setUpdateStatus: (status: UpdateStatus) => void; + setUpdateSettings: (settings: UpdateSettings) => void; + setIsChecking: (isChecking: boolean) => void; + setIsDismissed: (isDismissed: boolean) => void; + setLastChecked: (lastChecked: string | null) => void; +} + +export interface UIActions { + setIsOnline: (isOnline: boolean) => void; + setIsLoading: (isLoading: boolean) => void; + setIsInitialLoading: (isInitialLoading: boolean) => void; + setError: (error: string | null) => void; + addLog: (log: LogEntry) => void; + clearLogs: () => void; +} + +export interface AppActions extends + ConfigActions, + AuthActions, + RunnerActions, + JobsActions, + GitHubActions, + UpdateActions, + UIActions {} + +// ============================================================================= +// Full Store Type +// ============================================================================= + +export type AppStore = AppState & AppActions; + +// ============================================================================= +// Default State +// ============================================================================= + +export const defaultConfigState: ConfigSlice = { + theme: 'auto', + logLevel: 'info', + runnerLogLevel: 'warn', + maxLogScrollback: 500, + maxJobHistory: 10, + sleepProtection: 'never', + sleepProtectionConsented: false, + preserveWorkDir: 'never', + toolCacheLocation: 'persistent', + userFilter: { mode: 'just-me', allowlist: [] }, + power: DEFAULT_POWER_CONFIG, + notifications: DEFAULT_NOTIFICATIONS_CONFIG, + launchAtLogin: false, + hideOnStart: false, + runnerConfig: { + level: 'repo', + repoUrl: '', + orgName: '', + runnerName: '', + labels: 'self-hosted,macOS', + runnerCount: 4, + }, + targets: [], + maxConcurrentJobs: 4, +}; + +export const defaultAuthState: AuthSlice = { + user: null, + isAuthenticated: false, + isAuthenticating: false, + deviceCode: null, +}; + +export const defaultRunnerState: RunnerSlice = { + runnerState: { status: 'offline' }, + isDownloaded: false, + isConfigured: false, + runnerVersion: { version: null, url: null }, + availableVersions: [], + selectedVersion: '', + downloadProgress: null, + isLoadingVersions: false, + runnerDisplayName: null, + targetStatus: [], +}; + +export const defaultJobsState: JobsSlice = { + history: [], +}; + +export const defaultGitHubState: GitHubSlice = { + repos: [], + orgs: [], +}; + +export const defaultUpdateState: UpdateSlice = { + status: { status: 'idle', currentVersion: '' }, + settings: { autoCheck: true, checkIntervalHours: 24 }, + isChecking: false, + isDismissed: false, + lastChecked: null, +}; + +export const defaultUIState: UISlice = { + isOnline: true, + isLoading: false, + isInitialLoading: true, + error: null, + logs: [], +}; + +export const defaultAppState: AppState = { + config: defaultConfigState, + auth: defaultAuthState, + runner: defaultRunnerState, + jobs: defaultJobsState, + github: defaultGitHubState, + update: defaultUpdateState, + ui: defaultUIState, +}; diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts new file mode 100644 index 0000000..197d3d8 --- /dev/null +++ b/src/renderer/store/index.ts @@ -0,0 +1,106 @@ +/** + * Renderer-side Zustand store hook. + * + * This hook provides access to the synchronized store from the main process. + * State is automatically synced via zubridge IPC. + */ + +import { createUseStore, useDispatch as useZubridgeDispatch } from '@zubridge/electron'; +import type { AppState, AppStore } from '../../main/store/types'; + +// Create the store hook - this connects to the main process store via zubridge +export const useStore = createUseStore(); + +// Re-export dispatch hook with proper typing +export const useDispatch = () => useZubridgeDispatch(); + +// ============================================================================= +// Convenience hooks for common state slices +// ============================================================================= + +// Config hooks +export const useTheme = () => useStore((state) => state.config.theme); +export const useLogLevel = () => useStore((state) => state.config.logLevel); +export const useRunnerLogLevel = () => useStore((state) => state.config.runnerLogLevel); +export const useMaxLogScrollback = () => useStore((state) => state.config.maxLogScrollback); +export const useMaxJobHistory = () => useStore((state) => state.config.maxJobHistory); +export const useSleepProtection = () => useStore((state) => state.config.sleepProtection); +export const useSleepProtectionConsented = () => useStore((state) => state.config.sleepProtectionConsented); +export const usePreserveWorkDir = () => useStore((state) => state.config.preserveWorkDir); +export const useToolCacheLocation = () => useStore((state) => state.config.toolCacheLocation); +export const useUserFilter = () => useStore((state) => state.config.userFilter); +export const usePower = () => useStore((state) => state.config.power); +export const useNotifications = () => useStore((state) => state.config.notifications); +export const useLaunchAtLogin = () => useStore((state) => state.config.launchAtLogin); +export const useHideOnStart = () => useStore((state) => state.config.hideOnStart); +export const useRunnerConfig = () => useStore((state) => state.config.runnerConfig); +export const useTargets = () => useStore((state) => state.config.targets); + +// Auth hooks +export const useUser = () => useStore((state) => state.auth.user); +export const useIsAuthenticated = () => useStore((state) => state.auth.isAuthenticated); +export const useIsAuthenticating = () => useStore((state) => state.auth.isAuthenticating); +export const useDeviceCode = () => useStore((state) => state.auth.deviceCode); + +// Runner hooks +export const useRunnerState = () => useStore((state) => state.runner.runnerState); +export const useIsDownloaded = () => useStore((state) => state.runner.isDownloaded); +export const useIsConfigured = () => useStore((state) => state.runner.isConfigured); +export const useRunnerVersion = () => useStore((state) => state.runner.runnerVersion); +export const useAvailableVersions = () => useStore((state) => state.runner.availableVersions); +export const useSelectedVersion = () => useStore((state) => state.runner.selectedVersion); +export const useDownloadProgress = () => useStore((state) => state.runner.downloadProgress); +export const useIsLoadingVersions = () => useStore((state) => state.runner.isLoadingVersions); +export const useRunnerDisplayName = () => useStore((state) => state.runner.runnerDisplayName); +export const useTargetStatus = () => useStore((state) => state.runner.targetStatus); + +// Jobs hooks +export const useJobHistory = () => useStore((state) => state.jobs.history); + +// GitHub hooks +export const useRepos = () => useStore((state) => state.github.repos); +export const useOrgs = () => useStore((state) => state.github.orgs); + +// Update hooks +export const useUpdateStatus = () => useStore((state) => state.update.status); +export const useUpdateSettings = () => useStore((state) => state.update.settings); +export const useIsUpdateChecking = () => useStore((state) => state.update.isChecking); +export const useIsUpdateDismissed = () => useStore((state) => state.update.isDismissed); + +// UI hooks +export const useIsOnline = () => useStore((state) => state.ui.isOnline); +export const useIsLoading = () => useStore((state) => state.ui.isLoading); +export const useIsInitialLoading = () => useStore((state) => state.ui.isInitialLoading); +export const useError = () => useStore((state) => state.ui.error); +export const useLogs = () => useStore((state) => state.ui.logs); + +// ============================================================================= +// Action dispatchers +// ============================================================================= + +/** + * Dispatch action types for the store. + * These match the action names in the main process store. + */ +export type StoreAction = + // Config actions + | { type: 'setTheme'; payload: AppState['config']['theme'] } + | { type: 'setLogLevel'; payload: AppState['config']['logLevel'] } + | { type: 'setRunnerLogLevel'; payload: AppState['config']['runnerLogLevel'] } + | { type: 'setMaxLogScrollback'; payload: number } + | { type: 'setMaxJobHistory'; payload: number } + | { type: 'setSleepProtection'; payload: AppState['config']['sleepProtection'] } + | { type: 'consentToSleepProtection' } + | { type: 'setPreserveWorkDir'; payload: AppState['config']['preserveWorkDir'] } + | { type: 'setToolCacheLocation'; payload: AppState['config']['toolCacheLocation'] } + | { type: 'setUserFilter'; payload: AppState['config']['userFilter'] } + | { type: 'setPower'; payload: AppState['config']['power'] } + | { type: 'setNotifications'; payload: AppState['config']['notifications'] } + | { type: 'setLaunchAtLogin'; payload: boolean } + | { type: 'setHideOnStart'; payload: boolean } + | { type: 'updateRunnerConfig'; payload: Partial } + | { type: 'setTargets'; payload: AppState['config']['targets'] } + // UI actions + | { type: 'setError'; payload: string | null } + | { type: 'clearLogs' } + | { type: 'setIsDismissed'; payload: boolean }; From 245661e13da5676252a7adc4359ff1177c27c290 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Dec 2025 12:59:28 +0000 Subject: [PATCH 3/4] Migrate AppConfigContext to read from Zustand store - Add zubridge mock for renderer tests - Update AppConfigContext to read from Zustand store when synced - Use fallback state (from IPC) until zubridge is ready - Add settings IPC handler updates to sync with Zustand store - Configure webpack fallbacks for Node.js modules in renderer - Add userFilter mode validation when loading settings --- jest.config.renderer.js | 1 + src/main/ipc-handlers/settings.ts | 38 +++ src/renderer/contexts/AppConfigContext.tsx | 349 +++++++++++---------- test/__mocks__/@zubridge/electron.ts | 48 +++ test/setup-renderer.ts | 12 + webpack.config.js | 19 ++ 6 files changed, 305 insertions(+), 162 deletions(-) create mode 100644 test/__mocks__/@zubridge/electron.ts diff --git a/jest.config.renderer.js b/jest.config.renderer.js index ef39e4c..2a409cb 100644 --- a/jest.config.renderer.js +++ b/jest.config.renderer.js @@ -7,6 +7,7 @@ module.exports = { setupFilesAfterEnv: ['/test/setup-renderer.ts'], moduleNameMapper: { '\\.css$': '/test/mocks/styleMock.ts', + '^@zubridge/electron$': '/test/__mocks__/@zubridge/electron.ts', }, transform: { '^.+\\.tsx?$': ['ts-jest', { diff --git a/src/main/ipc-handlers/settings.ts b/src/main/ipc-handlers/settings.ts index 80d3268..de44c13 100644 --- a/src/main/ipc-handlers/settings.ts +++ b/src/main/ipc-handlers/settings.ts @@ -13,6 +13,8 @@ import { getLogger, } from '../app-state'; import { IPC_CHANNELS, SleepProtection, LogLevel } from '../../shared/types'; +import { store } from '../store'; +import { ThemeSetting } from '../store/types'; const log = () => getLogger(); @@ -58,6 +60,42 @@ export const registerSettingsHandlers = (): void => { saveConfig({ ...current, ...sanitizedSettings }); + // Update Zustand store to sync with renderer via zubridge + const storeState = store.getState(); + if (settings.theme !== undefined) { + storeState.setTheme(settings.theme as ThemeSetting); + } + if (settings.logLevel !== undefined) { + storeState.setLogLevel(settings.logLevel as LogLevel); + } + if (settings.runnerLogLevel !== undefined) { + storeState.setRunnerLogLevel(settings.runnerLogLevel as LogLevel); + } + if (settings.sleepProtection !== undefined) { + storeState.setSleepProtection(settings.sleepProtection as SleepProtection); + } + if (settings.userFilter !== undefined) { + storeState.setUserFilter(settings.userFilter); + } + if (settings.power !== undefined) { + storeState.setPower(settings.power); + } + if (settings.notifications !== undefined) { + storeState.setNotifications(settings.notifications); + } + if (settings.launchAtLogin !== undefined) { + storeState.setLaunchAtLogin(settings.launchAtLogin); + } + if (settings.targets !== undefined) { + storeState.setTargets(settings.targets); + } + if (settings.maxConcurrentJobs !== undefined) { + storeState.setMaxConcurrentJobs(settings.maxConcurrentJobs); + } + if (settings.runnerConfig !== undefined) { + storeState.updateRunnerConfig(settings.runnerConfig); + } + // Update sleep protection if setting changed if (settings.sleepProtection !== undefined) { setSleepProtectionSetting(settings.sleepProtection as SleepProtection); diff --git a/src/renderer/contexts/AppConfigContext.tsx b/src/renderer/contexts/AppConfigContext.tsx index fd01e11..7bd3d15 100644 --- a/src/renderer/contexts/AppConfigContext.tsx +++ b/src/renderer/contexts/AppConfigContext.tsx @@ -1,5 +1,26 @@ -import React, { createContext, useContext, useState, useEffect, useRef, useCallback, ReactNode } from 'react'; -import { LogEntry, SleepProtection, LogLevel, ToolCacheLocation, UserFilterConfig, PowerConfig, BatteryPauseThreshold, DEFAULT_POWER_CONFIG, NotificationsConfig, DEFAULT_NOTIFICATIONS_CONFIG } from '../../shared/types'; +/** + * AppConfigContext - provides app configuration state to React components. + * + * This context now reads state from the Zustand store (synced from main via zubridge) + * and updates via IPC calls (which update the main store and persist to disk). + */ + +import React, { createContext, useContext, useEffect, useRef, useCallback, useState, ReactNode } from 'react'; +import { + LogEntry, + SleepProtection, + LogLevel, + ToolCacheLocation, + UserFilterConfig, + PowerConfig, + BatteryPauseThreshold, + NotificationsConfig, + DEFAULT_POWER_CONFIG, + DEFAULT_NOTIFICATIONS_CONFIG, +} from '../../shared/types'; +import { + useStore, +} from '../store'; export type ThemeSetting = 'light' | 'dark' | 'auto'; @@ -72,40 +93,90 @@ interface AppConfigProviderProps { } export const AppConfigProvider: React.FC = ({ children }) => { - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [isOnline, setIsOnline] = useState(true); - - // Theme - const [theme, setThemeState] = useState('auto'); - - // Logging - const [logLevel, setLogLevelState] = useState('info'); - const [runnerLogLevel, setRunnerLogLevelState] = useState('warn'); + // Read state from Zustand store (synced from main via zubridge) + // Use optional chaining since store may not be initialized yet + const storeTheme = useStore((state) => state?.config?.theme ?? 'auto'); + const storeLogLevel = useStore((state) => state?.config?.logLevel ?? 'info'); + const storeRunnerLogLevel = useStore((state) => state?.config?.runnerLogLevel ?? 'warn'); + const storeMaxLogScrollback = useStore((state) => state?.config?.maxLogScrollback ?? 500); + const storeMaxJobHistory = useStore((state) => state?.config?.maxJobHistory ?? 10); + const storeSleepProtection = useStore((state) => state?.config?.sleepProtection ?? 'never'); + const storeSleepProtectionConsented = useStore((state) => state?.config?.sleepProtectionConsented ?? false); + const storePreserveWorkDir = useStore((state) => state?.config?.preserveWorkDir ?? 'never'); + const storeToolCacheLocation = useStore((state) => state?.config?.toolCacheLocation ?? 'persistent'); + const storeUserFilter = useStore((state) => state?.config?.userFilter ?? { mode: 'just-me' as const, allowlist: [] }); + const storePower = useStore((state) => state?.config?.power ?? DEFAULT_POWER_CONFIG); + const storeNotifications = useStore((state) => state?.config?.notifications ?? DEFAULT_NOTIFICATIONS_CONFIG); + const storeIsOnline = useStore((state) => state?.ui?.isOnline ?? true); + const storeIsLoading = useStore((state) => state?.ui?.isInitialLoading ?? true); + const storeError = useStore((state) => state?.ui?.error ?? null); + + // Local state for logs (handled via IPC subscription for real-time updates) const [logs, setLogs] = useState([]); const logsRef = useRef([]); - const [maxLogScrollback, setMaxLogScrollbackState] = useState(500); - const maxLogScrollbackRef = useRef(500); - - // Job history - const [maxJobHistory, setMaxJobHistoryState] = useState(10); - - // Sleep protection - const [sleepProtection, setSleepProtectionState] = useState('never'); - const [sleepProtectionConsented, setSleepProtectionConsentedState] = useState(false); - - // Runner settings - const [preserveWorkDir, setPreserveWorkDirState] = useState<'never' | 'session' | 'always'>('never'); - const [toolCacheLocation, setToolCacheLocationState] = useState('persistent'); - - // User filter - const [userFilter, setUserFilterState] = useState({ mode: 'just-me', allowlist: [] }); - - // Power settings - const [power, setPowerState] = useState(DEFAULT_POWER_CONFIG); - - // Notifications - const [notifications, setNotificationsState] = useState(DEFAULT_NOTIFICATIONS_CONFIG); + const maxLogScrollbackRef = useRef(storeMaxLogScrollback); + + // Check if zubridge has synced state from main + // useStore() without selector returns the full state - null if not yet synced + const storeState = useStore(); + const isZubridgeReady = storeState !== null && storeState !== undefined; + + // Fallback state for when zubridge isn't ready yet + const [fallbackState, setFallbackState] = useState<{ + theme: ThemeSetting; + logLevel: LogLevel; + runnerLogLevel: LogLevel; + maxLogScrollback: number; + maxJobHistory: number; + sleepProtection: SleepProtection; + sleepProtectionConsented: boolean; + preserveWorkDir: 'never' | 'session' | 'always'; + toolCacheLocation: ToolCacheLocation; + userFilter: UserFilterConfig; + power: PowerConfig; + notifications: NotificationsConfig; + isOnline: boolean; + isLoading: boolean; + error: string | null; + }>({ + theme: 'auto', + logLevel: 'info', + runnerLogLevel: 'warn', + maxLogScrollback: 500, + maxJobHistory: 10, + sleepProtection: 'never', + sleepProtectionConsented: false, + preserveWorkDir: 'never', + toolCacheLocation: 'persistent', + userFilter: { mode: 'just-me', allowlist: [] }, + power: DEFAULT_POWER_CONFIG, + notifications: DEFAULT_NOTIFICATIONS_CONFIG, + isOnline: true, + isLoading: true, + error: null, + }); + + // Use store values if zubridge is ready, otherwise use fallback + const theme = isZubridgeReady ? storeTheme : fallbackState.theme; + const logLevel = isZubridgeReady ? storeLogLevel : fallbackState.logLevel; + const runnerLogLevel = isZubridgeReady ? storeRunnerLogLevel : fallbackState.runnerLogLevel; + const maxLogScrollback = isZubridgeReady ? storeMaxLogScrollback : fallbackState.maxLogScrollback; + const maxJobHistory = isZubridgeReady ? storeMaxJobHistory : fallbackState.maxJobHistory; + const sleepProtection = isZubridgeReady ? storeSleepProtection : fallbackState.sleepProtection; + const sleepProtectionConsented = isZubridgeReady ? storeSleepProtectionConsented : fallbackState.sleepProtectionConsented; + const preserveWorkDir = isZubridgeReady ? storePreserveWorkDir : fallbackState.preserveWorkDir; + const toolCacheLocation = isZubridgeReady ? storeToolCacheLocation : fallbackState.toolCacheLocation; + const userFilter = isZubridgeReady ? storeUserFilter : fallbackState.userFilter; + const power = isZubridgeReady ? storePower : fallbackState.power; + const notifications = isZubridgeReady ? storeNotifications : fallbackState.notifications; + const isOnline = isZubridgeReady ? storeIsOnline : fallbackState.isOnline; + const isLoading = isZubridgeReady ? storeIsLoading : fallbackState.isLoading; + const error = isZubridgeReady ? storeError : fallbackState.error; + + // Keep ref in sync with store value + useEffect(() => { + maxLogScrollbackRef.current = maxLogScrollback; + }, [maxLogScrollback]); // Apply theme whenever it changes useEffect(() => { @@ -119,161 +190,114 @@ export const AppConfigProvider: React.FC = ({ children } } }, [theme]); - // Initialize settings from storage + // Initialize: load settings and subscribe to logs useEffect(() => { - const loadSettings = async () => { - try { - if (!window.localmost) { - setError('Preload script not loaded. window.localmost is undefined.'); - setIsLoading(false); - return; - } + const init = async () => { + if (!window.localmost) { + setFallbackState(prev => ({ + ...prev, + error: 'Preload script not loaded. window.localmost is undefined.', + isLoading: false, + })); + return; + } + // Load settings via IPC (fallback until zubridge syncs) + try { const settings = await window.localmost.settings.get(); - // Theme - if (settings.theme && typeof settings.theme === 'string' && ['light', 'dark', 'auto'].includes(settings.theme)) { - setThemeState(settings.theme as ThemeSetting); - } - - // Log scrollback - const savedMaxScrollback = settings.maxLogScrollback ? Number(settings.maxLogScrollback) : 500; - if (savedMaxScrollback > 0) { - setMaxLogScrollbackState(savedMaxScrollback); - maxLogScrollbackRef.current = savedMaxScrollback; - } - - // Job history - const savedMaxJobHistory = settings.maxJobHistory ? Number(settings.maxJobHistory) : 10; - if (savedMaxJobHistory >= 5 && savedMaxJobHistory <= 50) { - setMaxJobHistoryState(savedMaxJobHistory); - } - - // Sleep protection - if (settings.sleepProtection && ['never', 'when-busy', 'always'].includes(settings.sleepProtection as string)) { - setSleepProtectionState(settings.sleepProtection as SleepProtection); - } - if (settings.sleepProtectionConsented) { - setSleepProtectionConsentedState(true); - } - - // Preserve work dir - if (settings.preserveWorkDir && ['never', 'session', 'always'].includes(settings.preserveWorkDir as string)) { - setPreserveWorkDirState(settings.preserveWorkDir as 'never' | 'session' | 'always'); - } - - // Tool cache location - if (settings.toolCacheLocation && ['persistent', 'per-sandbox'].includes(settings.toolCacheLocation as string)) { - setToolCacheLocationState(settings.toolCacheLocation as ToolCacheLocation); - } - - // Log levels - if (settings.logLevel && ['debug', 'info', 'warn', 'error'].includes(settings.logLevel as string)) { - setLogLevelState(settings.logLevel as LogLevel); - } - if (settings.runnerLogLevel && ['debug', 'info', 'warn', 'error'].includes(settings.runnerLogLevel as string)) { - setRunnerLogLevelState(settings.runnerLogLevel as LogLevel); - } - - // User filter - if (settings.userFilter) { - const filter = settings.userFilter as UserFilterConfig; - if (filter.mode && ['everyone', 'just-me', 'allowlist'].includes(filter.mode)) { - setUserFilterState({ - mode: filter.mode, - allowlist: Array.isArray(filter.allowlist) ? filter.allowlist : [], - }); - } - } - - // Power settings - if (settings.power) { - const config = settings.power as PowerConfig; - setPowerState({ - ...DEFAULT_POWER_CONFIG, - pauseOnBattery: config.pauseOnBattery ?? DEFAULT_POWER_CONFIG.pauseOnBattery, - pauseOnVideoCall: config.pauseOnVideoCall ?? DEFAULT_POWER_CONFIG.pauseOnVideoCall, - videoCallGracePeriod: config.videoCallGracePeriod ?? DEFAULT_POWER_CONFIG.videoCallGracePeriod, - }); - } - - // Notifications settings - if (settings.notifications) { - const config = settings.notifications as NotificationsConfig; - setNotificationsState({ - ...DEFAULT_NOTIFICATIONS_CONFIG, - ...config, - }); - } - - setIsLoading(false); + setFallbackState(prev => ({ + ...prev, + theme: (settings.theme as ThemeSetting) || prev.theme, + logLevel: (settings.logLevel as LogLevel) || prev.logLevel, + runnerLogLevel: (settings.runnerLogLevel as LogLevel) || prev.runnerLogLevel, + maxLogScrollback: settings.maxLogScrollback ? Number(settings.maxLogScrollback) : prev.maxLogScrollback, + maxJobHistory: settings.maxJobHistory ? Number(settings.maxJobHistory) : prev.maxJobHistory, + sleepProtection: (settings.sleepProtection as SleepProtection) || prev.sleepProtection, + sleepProtectionConsented: settings.sleepProtectionConsented || prev.sleepProtectionConsented, + preserveWorkDir: (settings.preserveWorkDir as 'never' | 'session' | 'always') || prev.preserveWorkDir, + toolCacheLocation: (settings.toolCacheLocation as ToolCacheLocation) || prev.toolCacheLocation, + userFilter: settings.userFilter && ['just-me', 'allowlist', 'anyone'].includes((settings.userFilter as UserFilterConfig).mode) + ? (settings.userFilter as UserFilterConfig) + : prev.userFilter, + power: settings.power ? { ...DEFAULT_POWER_CONFIG, ...(settings.power as PowerConfig) } : prev.power, + notifications: settings.notifications ? { ...DEFAULT_NOTIFICATIONS_CONFIG, ...(settings.notifications as NotificationsConfig) } : prev.notifications, + isLoading: false, + })); + + // zubridge readiness is now detected automatically via useStore() return value } catch (err) { - setError(`Failed to load settings: ${(err as Error).message}`); - setIsLoading(false); + setFallbackState(prev => ({ + ...prev, + error: `Failed to load settings: ${(err as Error).message}`, + isLoading: false, + })); } }; - loadSettings(); + init(); // Subscribe to logs - const unsubLogs = window.localmost.logs.onEntry((entry: LogEntry) => { + const unsubLogs = window.localmost?.logs?.onEntry((entry: LogEntry) => { const max = maxLogScrollbackRef.current; logsRef.current = [...logsRef.current.slice(-(max - 1)), entry]; setLogs(logsRef.current); }); // Network status - window.localmost.network.isOnline().then(setIsOnline).catch(() => { + window.localmost?.network?.isOnline().then((online: boolean) => { + setFallbackState(prev => ({ ...prev, isOnline: online })); + }).catch(() => { // Default to online if check fails - setIsOnline(true); }); - const handleOnline = () => setIsOnline(true); - const handleOffline = () => setIsOnline(false); + const handleOnline = () => setFallbackState(prev => ({ ...prev, isOnline: true })); + const handleOffline = () => setFallbackState(prev => ({ ...prev, isOnline: false })); window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { - unsubLogs(); + unsubLogs?.(); window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); - // Setting updaters with persistence + // Setting updaters - call IPC which updates main store + persists const setTheme = useCallback(async (newTheme: ThemeSetting) => { - setThemeState(newTheme); + // Optimistic update for fallback state + setFallbackState(prev => ({ ...prev, theme: newTheme })); try { await window.localmost.settings.set({ theme: newTheme }); } catch { - // Optimistic update - UI already changed, log error but don't revert + // Store update failed, but zubridge will sync if main store was updated } }, []); const setLogLevel = useCallback(async (newLevel: LogLevel) => { - setLogLevelState(newLevel); + setFallbackState(prev => ({ ...prev, logLevel: newLevel })); try { await window.localmost.settings.set({ logLevel: newLevel }); } catch { - // Optimistic update - UI already changed + // Optimistic update handled by zubridge sync } }, []); const setRunnerLogLevel = useCallback(async (newLevel: LogLevel) => { - setRunnerLogLevelState(newLevel); + setFallbackState(prev => ({ ...prev, runnerLogLevel: newLevel })); try { await window.localmost.settings.set({ runnerLogLevel: newLevel }); } catch { - // Optimistic update - UI already changed + // Optimistic update handled by zubridge sync } }, []); const setMaxLogScrollback = useCallback(async (newMax: number) => { - setMaxLogScrollbackState(newMax); maxLogScrollbackRef.current = newMax; + setFallbackState(prev => ({ ...prev, maxLogScrollback: newMax })); try { await window.localmost.settings.set({ maxLogScrollback: newMax }); } catch { - // Optimistic update - UI already changed + // Optimistic update handled by zubridge sync } // Trim existing logs if needed if (logsRef.current.length > newMax) { @@ -283,121 +307,122 @@ export const AppConfigProvider: React.FC = ({ children } }, []); const setMaxJobHistory = useCallback(async (newMax: number) => { - setMaxJobHistoryState(newMax); + setFallbackState(prev => ({ ...prev, maxJobHistory: newMax })); try { await window.localmost.settings.set({ maxJobHistory: newMax }); await window.localmost.jobs.setMaxHistory(newMax); } catch { - // Optimistic update - UI already changed + // Optimistic update handled by zubridge sync } }, []); const setSleepProtection = useCallback(async (newSetting: SleepProtection) => { - setSleepProtectionState(newSetting); + setFallbackState(prev => ({ ...prev, sleepProtection: newSetting })); try { await window.localmost.settings.set({ sleepProtection: newSetting }); } catch { - // Optimistic update - UI already changed + // Optimistic update handled by zubridge sync } }, []); const consentToSleepProtection = useCallback(async () => { - setSleepProtectionConsentedState(true); + setFallbackState(prev => ({ ...prev, sleepProtectionConsented: true })); try { await window.localmost.settings.set({ sleepProtectionConsented: true }); } catch { - // Optimistic update - UI already changed + // Optimistic update handled by zubridge sync } }, []); const setPreserveWorkDir = useCallback(async (setting: 'never' | 'session' | 'always') => { - setPreserveWorkDirState(setting); + setFallbackState(prev => ({ ...prev, preserveWorkDir: setting })); try { await window.localmost.settings.set({ preserveWorkDir: setting }); } catch { - // Optimistic update - UI already changed + // Optimistic update handled by zubridge sync } }, []); const setToolCacheLocation = useCallback(async (setting: ToolCacheLocation) => { - setToolCacheLocationState(setting); + setFallbackState(prev => ({ ...prev, toolCacheLocation: setting })); try { await window.localmost.settings.set({ toolCacheLocation: setting }); } catch { - // Optimistic update - UI already changed + // Optimistic update handled by zubridge sync } }, []); const setUserFilter = useCallback(async (filter: UserFilterConfig) => { - setUserFilterState(filter); + setFallbackState(prev => ({ ...prev, userFilter: filter })); try { await window.localmost.settings.set({ userFilter: filter }); } catch { - // Optimistic update - UI already changed + // Optimistic update handled by zubridge sync } }, []); const setPower = useCallback(async (config: PowerConfig) => { - setPowerState(config); + setFallbackState(prev => ({ ...prev, power: config })); try { await window.localmost.settings.set({ power: config }); } catch { - // Optimistic update - UI already changed + // Optimistic update handled by zubridge sync } }, []); const setPauseOnBattery = useCallback(async (threshold: BatteryPauseThreshold) => { const newConfig = { ...power, pauseOnBattery: threshold }; - setPowerState(newConfig); + setFallbackState(prev => ({ ...prev, power: newConfig })); try { await window.localmost.settings.set({ power: newConfig }); } catch { - // Optimistic update - UI already changed + // Optimistic update handled by zubridge sync } }, [power]); const setPauseOnVideoCall = useCallback(async (enabled: boolean) => { const newConfig = { ...power, pauseOnVideoCall: enabled }; - setPowerState(newConfig); + setFallbackState(prev => ({ ...prev, power: newConfig })); try { await window.localmost.settings.set({ power: newConfig }); } catch { - // Optimistic update - UI already changed + // Optimistic update handled by zubridge sync } }, [power]); const setNotifications = useCallback(async (config: NotificationsConfig) => { - setNotificationsState(config); + setFallbackState(prev => ({ ...prev, notifications: config })); try { await window.localmost.settings.set({ notifications: config }); } catch { - // Optimistic update - UI already changed + // Optimistic update handled by zubridge sync } }, []); const setNotifyOnPause = useCallback(async (enabled: boolean) => { const newConfig = { ...notifications, notifyOnPause: enabled }; - setNotificationsState(newConfig); + setFallbackState(prev => ({ ...prev, notifications: newConfig })); try { await window.localmost.settings.set({ notifications: newConfig }); } catch { - // Optimistic update - UI already changed + // Optimistic update handled by zubridge sync } }, [notifications]); const setNotifyOnJobEvents = useCallback(async (enabled: boolean) => { const newConfig = { ...notifications, notifyOnJobEvents: enabled }; - setNotificationsState(newConfig); + setFallbackState(prev => ({ ...prev, notifications: newConfig })); try { await window.localmost.settings.set({ notifications: newConfig }); } catch { - // Optimistic update - UI already changed + // Optimistic update handled by zubridge sync } }, [notifications]); const clearLogs = useCallback(() => { logsRef.current = []; setLogs([]); + window.localmost?.logs?.clear(); }, []); const value: AppConfigContextValue = { @@ -410,9 +435,9 @@ export const AppConfigProvider: React.FC = ({ children } logs, clearLogs, maxLogScrollback, - setMaxLogScrollback, - maxJobHistory, setMaxJobHistory, + maxJobHistory, + setMaxLogScrollback, sleepProtection, setSleepProtection, sleepProtectionConsented, diff --git a/test/__mocks__/@zubridge/electron.ts b/test/__mocks__/@zubridge/electron.ts new file mode 100644 index 0000000..5251437 --- /dev/null +++ b/test/__mocks__/@zubridge/electron.ts @@ -0,0 +1,48 @@ +/** + * Mock for @zubridge/electron module in tests. + * This prevents the real createUseStore from being called during module initialization. + */ + +import { useCallback } from 'react'; + +// Mock state that tests can modify +let mockState: unknown = null; + +/** + * Set the mock state for tests. + */ +export function __setMockState(state: unknown): void { + mockState = state; +} + +/** + * Reset the mock state. + */ +export function __resetMockState(): void { + mockState = null; +} + +/** + * Mock createUseStore - returns a hook that reads from mockState. + * Importantly, we still run the selector even with null state, since + * the real selectors use optional chaining to handle null gracefully. + */ +export function createUseStore() { + return function useStore(selector?: (state: T) => R): R | T | null { + if (selector) { + // Run the selector even with null state - selectors should handle null gracefully + return selector(mockState as T); + } + return mockState as T; + }; +} + +/** + * Mock useDispatch - returns a no-op dispatcher. + */ +export function useDispatch<_T>() { + return useCallback((action: unknown) => { + // No-op in tests - actions are not dispatched + console.debug('[zubridge mock] dispatch:', action); + }, []); +} diff --git a/test/setup-renderer.ts b/test/setup-renderer.ts index bbc2652..dfe9e40 100644 --- a/test/setup-renderer.ts +++ b/test/setup-renderer.ts @@ -35,6 +35,7 @@ export interface MockLocalmost { onEntry: jest.Mock; getPath: jest.Mock; write: jest.Mock; + clear: jest.Mock; }; jobs: { getHistory: jest.Mock; @@ -113,6 +114,7 @@ const mockLocalmost: MockLocalmost = { onEntry: jest.fn().mockReturnValue(() => {}), getPath: jest.fn().mockResolvedValue('/tmp/localmost.log'), write: jest.fn(), + clear: jest.fn(), }, jobs: { getHistory: jest.fn().mockResolvedValue([]), @@ -154,6 +156,16 @@ Object.defineProperty(window, 'localmost', { writable: true, }); +// Mock zubridge for Zustand store sync +Object.defineProperty(window, 'zubridge', { + value: { + getState: jest.fn().mockReturnValue(null), + subscribe: jest.fn().mockReturnValue(() => {}), + dispatch: jest.fn(), + }, + writable: true, +}); + // Mock matchMedia Object.defineProperty(window, 'matchMedia', { writable: true, diff --git a/webpack.config.js b/webpack.config.js index dcfc3ef..9d86679 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -88,6 +88,25 @@ const rendererConfig = { chunkFilename: '[name].js', publicPath: './', }, + resolve: { + ...commonConfig.resolve, + // Provide fallbacks for Node.js modules used by zubridge + fallback: { + path: false, + fs: false, + os: false, + crypto: false, + buffer: false, + stream: false, + util: false, + assert: false, + http: false, + https: false, + zlib: false, + url: false, + querystring: false, + }, + }, module: { rules: [ { From cb538854ddcd8038d4fd933752377ffc3a97e979 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Dec 2025 13:07:48 +0000 Subject: [PATCH 4/4] Migrate RunnerContext and UpdateContext to read from Zustand store Both contexts now follow the same pattern as AppConfigContext: - Read state from Zustand store when zubridge has synced - Use fallback state (from IPC) until zubridge is ready - Actions continue to call IPC, which updates the main store - zubridge automatically syncs changes back to renderer --- src/renderer/contexts/RunnerContext.tsx | 335 ++++++++++++++++-------- src/renderer/contexts/UpdateContext.tsx | 88 +++++-- 2 files changed, 282 insertions(+), 141 deletions(-) diff --git a/src/renderer/contexts/RunnerContext.tsx b/src/renderer/contexts/RunnerContext.tsx index e0226ed..d906194 100644 --- a/src/renderer/contexts/RunnerContext.tsx +++ b/src/renderer/contexts/RunnerContext.tsx @@ -1,5 +1,13 @@ +/** + * RunnerContext - provides runner state to React components. + * + * This context now reads state from the Zustand store (synced from main via zubridge) + * and updates via IPC calls (which update the main store). + */ + import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; import { GitHubUser, GitHubRepo, GitHubOrg, RunnerState, JobHistoryEntry, DownloadProgress, DeviceCodeInfo, RunnerRelease, Target, RunnerProxyStatus } from '../../shared/types'; +import { useStore } from '../store'; interface RunnerConfig { level: 'repo' | 'org'; @@ -62,96 +70,147 @@ interface RunnerProviderProps { children: ReactNode; } -export const RunnerProvider: React.FC = ({ children }) => { - // Auth state - const [user, setUser] = useState(null); - const [isAuthenticating, setIsAuthenticating] = useState(false); - const [deviceCode, setDeviceCode] = useState(null); - - // Repos and orgs - const [repos, setRepos] = useState([]); - const [orgs, setOrgs] = useState([]); - - // Runner binary - const [isDownloaded, setIsDownloaded] = useState(false); - const [runnerVersion, setRunnerVersion] = useState<{ version: string | null; url: string | null }>({ version: null, url: null }); - const [availableVersions, setAvailableVersions] = useState([]); - const [selectedVersion, setSelectedVersion] = useState(''); - const [downloadProgress, setDownloadProgress] = useState(null); - const [isLoadingVersions, setIsLoadingVersions] = useState(false); +// Default runner config +const defaultRunnerConfig: RunnerConfig = { + level: 'repo', + repoUrl: '', + orgName: '', + runnerName: '', + labels: 'self-hosted,macOS', + runnerCount: 4, +}; - // Runner configuration - const [isConfigured, setIsConfigured] = useState(false); - const [runnerConfig, setRunnerConfig] = useState({ - level: 'repo', - repoUrl: '', - orgName: '', - runnerName: '', - labels: 'self-hosted,macOS', - runnerCount: 4, +export const RunnerProvider: React.FC = ({ children }) => { + // Check if zubridge has synced state from main + const storeState = useStore(); + const isZubridgeReady = storeState !== null && storeState !== undefined; + + // Read state from Zustand store when ready + const storeUser = useStore((state) => state?.auth?.user ?? null); + const storeIsAuthenticating = useStore((state) => state?.auth?.isAuthenticating ?? false); + const storeDeviceCode = useStore((state) => state?.auth?.deviceCode ?? null); + const storeRepos = useStore((state) => state?.github?.repos ?? []); + const storeOrgs = useStore((state) => state?.github?.orgs ?? []); + const storeIsDownloaded = useStore((state) => state?.runner?.isDownloaded ?? false); + const storeRunnerVersion = useStore((state) => state?.runner?.runnerVersion ?? { version: null, url: null }); + const storeAvailableVersions = useStore((state) => state?.runner?.availableVersions ?? []); + const storeSelectedVersion = useStore((state) => state?.runner?.selectedVersion ?? ''); + const storeDownloadProgress = useStore((state) => state?.runner?.downloadProgress ?? null); + const storeIsLoadingVersions = useStore((state) => state?.runner?.isLoadingVersions ?? false); + const storeIsConfigured = useStore((state) => state?.runner?.isConfigured ?? false); + const storeRunnerConfig = useStore((state) => state?.config?.runnerConfig ?? defaultRunnerConfig); + const storeRunnerDisplayName = useStore((state) => state?.runner?.runnerDisplayName ?? null); + const storeTargets = useStore((state) => state?.config?.targets ?? []); + const storeTargetStatus = useStore((state) => state?.runner?.targetStatus ?? []); + const storeRunnerState = useStore((state) => state?.runner?.runnerState ?? { status: 'offline' }); + const storeJobHistory = useStore((state) => state?.jobs?.history ?? []); + const storeIsLoading = useStore((state) => state?.ui?.isLoading ?? false); + const storeIsInitialLoading = useStore((state) => state?.ui?.isInitialLoading ?? true); + const storeError = useStore((state) => state?.ui?.error ?? null); + + // Fallback state for when zubridge isn't ready + const [fallbackState, setFallbackState] = useState({ + user: null as GitHubUser | null, + isAuthenticating: false, + deviceCode: null as DeviceCodeInfo | null, + repos: [] as GitHubRepo[], + orgs: [] as GitHubOrg[], + isDownloaded: false, + runnerVersion: { version: null, url: null } as { version: string | null; url: string | null }, + availableVersions: [] as RunnerRelease[], + selectedVersion: '', + downloadProgress: null as DownloadProgress | null, + isLoadingVersions: false, + isConfigured: false, + runnerConfig: defaultRunnerConfig, + runnerDisplayName: null as string | null, + targets: [] as Target[], + targetStatus: [] as RunnerProxyStatus[], + runnerState: { status: 'offline' } as RunnerState, + jobHistory: [] as JobHistoryEntry[], + isLoading: false, + isInitialLoading: true, + error: null as string | null, }); - const [runnerDisplayName, setRunnerDisplayName] = useState(null); - - // Targets - const [targets, setTargets] = useState([]); - const [targetStatus, setTargetStatus] = useState([]); - - // Runner status - const [runnerState, setRunnerState] = useState({ status: 'offline' }); - const [jobHistory, setJobHistory] = useState([]); - - // Loading states - const [isLoading, setIsLoading] = useState(false); - const [isInitialLoading, setIsInitialLoading] = useState(true); - const [error, setError] = useState(null); - // Load initial state + // Use store values if ready, otherwise fallback + const user = isZubridgeReady ? storeUser : fallbackState.user; + const isAuthenticating = isZubridgeReady ? storeIsAuthenticating : fallbackState.isAuthenticating; + const deviceCode = isZubridgeReady ? storeDeviceCode : fallbackState.deviceCode; + const repos = isZubridgeReady ? storeRepos : fallbackState.repos; + const orgs = isZubridgeReady ? storeOrgs : fallbackState.orgs; + const isDownloaded = isZubridgeReady ? storeIsDownloaded : fallbackState.isDownloaded; + const runnerVersion = isZubridgeReady ? storeRunnerVersion : fallbackState.runnerVersion; + const availableVersions = isZubridgeReady ? storeAvailableVersions : fallbackState.availableVersions; + const selectedVersion = isZubridgeReady ? storeSelectedVersion : fallbackState.selectedVersion; + const downloadProgress = isZubridgeReady ? storeDownloadProgress : fallbackState.downloadProgress; + const isLoadingVersions = isZubridgeReady ? storeIsLoadingVersions : fallbackState.isLoadingVersions; + const isConfigured = isZubridgeReady ? storeIsConfigured : fallbackState.isConfigured; + const runnerConfig = isZubridgeReady ? storeRunnerConfig : fallbackState.runnerConfig; + const runnerDisplayName = isZubridgeReady ? storeRunnerDisplayName : fallbackState.runnerDisplayName; + const targets = isZubridgeReady ? storeTargets : fallbackState.targets; + const targetStatus = isZubridgeReady ? storeTargetStatus : fallbackState.targetStatus; + const runnerState = isZubridgeReady ? storeRunnerState : fallbackState.runnerState; + const jobHistory = isZubridgeReady ? storeJobHistory : fallbackState.jobHistory; + const isLoading = isZubridgeReady ? storeIsLoading : fallbackState.isLoading; + const isInitialLoading = isZubridgeReady ? storeIsInitialLoading : fallbackState.isInitialLoading; + const error = isZubridgeReady ? storeError : fallbackState.error; + + // Load initial state via IPC (fallback until zubridge syncs) useEffect(() => { const loadState = async () => { try { // Check auth status const authStatus = await window.localmost.github.getAuthStatus(); if (authStatus.isAuthenticated && authStatus.user) { - setUser(authStatus.user); + setFallbackState(prev => ({ ...prev, user: authStatus.user })); loadReposAndOrgs(); } // Check runner status - setIsDownloaded(await window.localmost.runner.isDownloaded()); + const downloaded = await window.localmost.runner.isDownloaded(); + setFallbackState(prev => ({ ...prev, isDownloaded: downloaded })); + const configured = await window.localmost.runner.isConfigured(); - setIsConfigured(configured); + setFallbackState(prev => ({ ...prev, isConfigured: configured })); // Load version info const version = await window.localmost.runner.getVersion(); - setRunnerVersion(version); + setFallbackState(prev => ({ ...prev, runnerVersion: version })); // Load runner config const settings = await window.localmost.settings.get(); const savedConfig = settings.runnerConfig as Partial | undefined; if (savedConfig) { - setRunnerConfig(prev => ({ + setFallbackState(prev => ({ ...prev, - level: savedConfig.level || prev.level, - repoUrl: savedConfig.repoUrl || prev.repoUrl, - orgName: savedConfig.orgName || prev.orgName, - runnerName: savedConfig.runnerName || prev.runnerName, - labels: savedConfig.labels || prev.labels, - runnerCount: savedConfig.runnerCount || prev.runnerCount, + runnerConfig: { + ...prev.runnerConfig, + level: savedConfig.level || prev.runnerConfig.level, + repoUrl: savedConfig.repoUrl || prev.runnerConfig.repoUrl, + orgName: savedConfig.orgName || prev.runnerConfig.orgName, + runnerName: savedConfig.runnerName || prev.runnerConfig.runnerName, + labels: savedConfig.labels || prev.runnerConfig.labels, + runnerCount: savedConfig.runnerCount || prev.runnerConfig.runnerCount, + }, })); } else { // Default runner name based on hostname const hostname = await window.localmost.app.getHostname(); - setRunnerConfig(prev => ({ + setFallbackState(prev => ({ ...prev, - runnerName: `localmost.${hostname}`, + runnerConfig: { + ...prev.runnerConfig, + runnerName: `localmost.${hostname}`, + }, })); } // Get display name if (configured) { const displayName = await window.localmost.runner.getDisplayName(); - setRunnerDisplayName(displayName); + setFallbackState(prev => ({ ...prev, runnerDisplayName: displayName })); } // Load available versions @@ -159,47 +218,65 @@ export const RunnerProvider: React.FC = ({ children }) => { // Get initial runner status const status = await window.localmost.runner.getStatus(); - setRunnerState(status); + setFallbackState(prev => ({ ...prev, runnerState: status })); // Get initial job history const history = await window.localmost.jobs.getHistory(); - setJobHistory(history); + setFallbackState(prev => ({ ...prev, jobHistory: history })); // Load targets and their status const loadedTargets = await window.localmost.targets.list(); - setTargets(loadedTargets); + setFallbackState(prev => ({ ...prev, targets: loadedTargets })); const loadedStatus = await window.localmost.targets.getStatus(); - setTargetStatus(loadedStatus); + setFallbackState(prev => ({ ...prev, targetStatus: loadedStatus })); } catch (err) { - setError(`Failed to load runner state: ${(err as Error).message}`); + setFallbackState(prev => ({ + ...prev, + error: `Failed to load runner state: ${(err as Error).message}`, + })); } finally { - setIsInitialLoading(false); + setFallbackState(prev => ({ ...prev, isInitialLoading: false })); } }; loadState(); // Subscribe to status updates - const unsubStatus = window.localmost.runner.onStatusUpdate(setRunnerState); - const unsubJobHistory = window.localmost.jobs.onHistoryUpdate(setJobHistory); - const unsubDeviceCode = window.localmost.github.onDeviceCode(setDeviceCode); - const unsubTargetStatus = window.localmost.targets.onStatusUpdate(setTargetStatus); + const unsubStatus = window.localmost.runner.onStatusUpdate((status: RunnerState) => { + setFallbackState(prev => ({ ...prev, runnerState: status })); + }); + const unsubJobHistory = window.localmost.jobs.onHistoryUpdate((history: JobHistoryEntry[]) => { + setFallbackState(prev => ({ ...prev, jobHistory: history })); + }); + const unsubDeviceCode = window.localmost.github.onDeviceCode((code: DeviceCodeInfo) => { + setFallbackState(prev => ({ ...prev, deviceCode: code })); + }); + const unsubTargetStatus = window.localmost.targets.onStatusUpdate((status: RunnerProxyStatus[]) => { + setFallbackState(prev => ({ ...prev, targetStatus: status })); + }); const unsubDownload = window.localmost.runner.onDownloadProgress(async (progress: DownloadProgress) => { if (progress.phase === 'complete') { - setDownloadProgress(null); - setIsDownloaded(true); - setIsLoading(false); + setFallbackState(prev => ({ + ...prev, + downloadProgress: null, + isDownloaded: true, + isLoading: false, + })); const version = await window.localmost.runner.getVersion(); - setRunnerVersion(version); - if (version.version) { - setSelectedVersion(version.version); - } + setFallbackState(prev => ({ + ...prev, + runnerVersion: version, + selectedVersion: version.version || prev.selectedVersion, + })); } else if (progress.phase === 'error') { - setDownloadProgress(null); - setError(progress.message); - setIsLoading(false); + setFallbackState(prev => ({ + ...prev, + downloadProgress: null, + error: progress.message, + isLoading: false, + })); } else { - setDownloadProgress(progress); + setFallbackState(prev => ({ ...prev, downloadProgress: progress })); } }); @@ -213,17 +290,18 @@ export const RunnerProvider: React.FC = ({ children }) => { }, []); const loadAvailableVersions = async (installedVersion?: string | null) => { - setIsLoadingVersions(true); + setFallbackState(prev => ({ ...prev, isLoadingVersions: true })); const result = await window.localmost.runner.getAvailableVersions(); if (result.success && result.versions.length > 0) { - setAvailableVersions(result.versions); - if (installedVersion && result.versions.some((v: RunnerRelease) => v.version === installedVersion)) { - setSelectedVersion(installedVersion); - } else { - setSelectedVersion(result.versions[0].version); - } + setFallbackState(prev => ({ + ...prev, + availableVersions: result.versions, + selectedVersion: installedVersion && result.versions.some((v: RunnerRelease) => v.version === installedVersion) + ? installedVersion + : result.versions[0].version, + })); } - setIsLoadingVersions(false); + setFallbackState(prev => ({ ...prev, isLoadingVersions: false })); }; const loadReposAndOrgs = async () => { @@ -232,10 +310,10 @@ export const RunnerProvider: React.FC = ({ children }) => { window.localmost.github.getOrgs(), ]); if (reposResult.success && reposResult.repos) { - setRepos(reposResult.repos); + setFallbackState(prev => ({ ...prev, repos: reposResult.repos })); } if (orgsResult.success && orgsResult.orgs) { - setOrgs(orgsResult.orgs); + setFallbackState(prev => ({ ...prev, orgs: orgsResult.orgs })); } }; @@ -245,14 +323,14 @@ export const RunnerProvider: React.FC = ({ children }) => { const refreshTargets = useCallback(async () => { const loadedTargets = await window.localmost.targets.list(); - setTargets(loadedTargets); + setFallbackState(prev => ({ ...prev, targets: loadedTargets })); // Also refresh isConfigured since adding/removing targets changes it const configured = await window.localmost.runner.isConfigured(); - setIsConfigured(configured); + setFallbackState(prev => ({ ...prev, isConfigured: configured })); // Update display name if we became configured if (configured) { const displayName = await window.localmost.runner.getDisplayName(); - setRunnerDisplayName(displayName); + setFallbackState(prev => ({ ...prev, runnerDisplayName: displayName })); // Auto-start runner if configured but offline (e.g., first target was just added) const status = await window.localmost.runner.getStatus(); if (status.status === 'offline') { @@ -262,34 +340,46 @@ export const RunnerProvider: React.FC = ({ children }) => { }, []); const login = useCallback(async () => { - setIsAuthenticating(true); - setError(null); - setDeviceCode(null); + setFallbackState(prev => ({ + ...prev, + isAuthenticating: true, + error: null, + deviceCode: null, + })); const result = await window.localmost.github.startDeviceFlow(); if (result.success && result.user) { - setUser(result.user); + setFallbackState(prev => ({ ...prev, user: result.user })); loadReposAndOrgs(); } else { - setError(result.error || 'Authentication failed'); + setFallbackState(prev => ({ ...prev, error: result.error || 'Authentication failed' })); } - setIsAuthenticating(false); - setDeviceCode(null); + setFallbackState(prev => ({ + ...prev, + isAuthenticating: false, + deviceCode: null, + })); }, []); const logout = useCallback(async () => { await window.localmost.github.logout(); - setUser(null); - setRepos([]); - setOrgs([]); + setFallbackState(prev => ({ + ...prev, + user: null, + repos: [], + orgs: [], + })); }, []); const downloadRunner = useCallback(async () => { - setIsLoading(true); - setError(null); - setDownloadProgress({ phase: 'downloading', percent: 0, message: 'Starting...' }); + setFallbackState(prev => ({ + ...prev, + isLoading: true, + error: null, + downloadProgress: { phase: 'downloading', percent: 0, message: 'Starting...' }, + })); if (selectedVersion) { await window.localmost.runner.setDownloadVersion(selectedVersion); @@ -297,14 +387,24 @@ export const RunnerProvider: React.FC = ({ children }) => { const result = await window.localmost.runner.download(); if (!result.success) { - setError(result.error || 'Download failed'); - setIsLoading(false); - setDownloadProgress(null); + setFallbackState(prev => ({ + ...prev, + error: result.error || 'Download failed', + isLoading: false, + downloadProgress: null, + })); } }, [selectedVersion]); + const setSelectedVersionCallback = useCallback((version: string) => { + setFallbackState(prev => ({ ...prev, selectedVersion: version })); + }, []); + const updateRunnerConfig = useCallback(async (updates: Partial) => { - setRunnerConfig(prev => ({ ...prev, ...updates })); + setFallbackState(prev => ({ + ...prev, + runnerConfig: { ...prev.runnerConfig, ...updates }, + })); const settings = await window.localmost.settings.get(); const currentConfig = (settings.runnerConfig || {}) as Record; @@ -322,33 +422,40 @@ export const RunnerProvider: React.FC = ({ children }) => { return { success: false, error: 'Please select an organization' }; } - setIsLoading(true); - setError(null); + setFallbackState(prev => ({ + ...prev, + isLoading: true, + error: null, + })); const result = await window.localmost.runner.configure({ level: runnerConfig.level, repoUrl: runnerConfig.level === 'repo' ? runnerConfig.repoUrl : undefined, orgName: runnerConfig.level === 'org' ? runnerConfig.orgName : undefined, runnerName: runnerConfig.runnerName.trim(), - labels: runnerConfig.labels.split(',').map(l => l.trim()).filter(Boolean), + labels: runnerConfig.labels.split(',').map((l: string) => l.trim()).filter(Boolean), runnerCount: runnerConfig.runnerCount, }); if (result.success) { - setIsConfigured(true); + setFallbackState(prev => ({ ...prev, isConfigured: true })); // Save the full runnerConfig so re-registration has runnerName, level, etc. await window.localmost.settings.set({ runnerConfig }); await window.localmost.runner.start(); const displayName = await window.localmost.runner.getDisplayName(); - setRunnerDisplayName(displayName); + setFallbackState(prev => ({ ...prev, runnerDisplayName: displayName })); } else { - setError(result.error || 'Configuration failed'); + setFallbackState(prev => ({ ...prev, error: result.error || 'Configuration failed' })); } - setIsLoading(false); + setFallbackState(prev => ({ ...prev, isLoading: false })); return result; }, [runnerConfig]); + const setError = useCallback((err: string | null) => { + setFallbackState(prev => ({ ...prev, error: err })); + }, []); + const value: RunnerContextValue = { user, isAuthenticating, @@ -362,7 +469,7 @@ export const RunnerProvider: React.FC = ({ children }) => { runnerVersion, availableVersions, selectedVersion, - setSelectedVersion, + setSelectedVersion: setSelectedVersionCallback, downloadProgress, isLoadingVersions, downloadRunner, diff --git a/src/renderer/contexts/UpdateContext.tsx b/src/renderer/contexts/UpdateContext.tsx index 6810e2c..594f22c 100644 --- a/src/renderer/contexts/UpdateContext.tsx +++ b/src/renderer/contexts/UpdateContext.tsx @@ -1,5 +1,13 @@ +/** + * UpdateContext - provides update state to React components. + * + * This context now reads state from the Zustand store (synced from main via zubridge) + * and updates via IPC calls (which update the main store). + */ + import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; import { UpdateStatus, UpdateSettings } from '../../shared/types'; +import { useStore } from '../store'; interface UpdateContextValue { // Current update state @@ -38,13 +46,35 @@ interface UpdateProviderProps { } export const UpdateProvider: React.FC = ({ children }) => { - const [status, setStatus] = useState(defaultStatus); - const [settings, setSettingsState] = useState(defaultSettings); - const [isChecking, setIsChecking] = useState(false); - const [isDismissed, setIsDismissed] = useState(false); - const [lastChecked, setLastChecked] = useState(null); - - // Load initial status and settings + // Check if zubridge has synced state from main + const storeState = useStore(); + const isZubridgeReady = storeState !== null && storeState !== undefined; + + // Read state from Zustand store when ready + const storeStatus = useStore((state) => state?.update?.status ?? defaultStatus); + const storeSettings = useStore((state) => state?.update?.settings ?? defaultSettings); + const storeIsChecking = useStore((state) => state?.update?.isChecking ?? false); + const storeIsDismissed = useStore((state) => state?.update?.isDismissed ?? false); + const storeLastChecked = useStore((state) => state?.update?.lastChecked ?? null); + + // Fallback state for when zubridge isn't ready + const [fallbackState, setFallbackState] = useState({ + status: defaultStatus, + settings: defaultSettings, + isChecking: false, + isDismissed: false, + lastChecked: null as string | null, + }); + + // Use store values if ready, otherwise fallback + const status = isZubridgeReady ? storeStatus : fallbackState.status; + const settings = isZubridgeReady ? storeSettings : fallbackState.settings; + const isChecking = isZubridgeReady ? storeIsChecking : fallbackState.isChecking; + const isDismissed = isZubridgeReady ? storeIsDismissed : fallbackState.isDismissed; + const lastCheckedStr = isZubridgeReady ? storeLastChecked : fallbackState.lastChecked; + const lastChecked = lastCheckedStr ? new Date(lastCheckedStr) : null; + + // Load initial status and settings via IPC (fallback until zubridge syncs) useEffect(() => { const init = async () => { if (!window.localmost?.update) return; @@ -52,7 +82,7 @@ export const UpdateProvider: React.FC = ({ children }) => { // Get current status try { const currentStatus = await window.localmost.update.getStatus(); - setStatus(currentStatus); + setFallbackState(prev => ({ ...prev, status: currentStatus })); } catch { // Ignore errors on initial load } @@ -62,10 +92,13 @@ export const UpdateProvider: React.FC = ({ children }) => { const savedSettings = await window.localmost.settings.get(); if (savedSettings.updateSettings) { const updateSettings = savedSettings.updateSettings as UpdateSettings; - setSettingsState({ - autoCheck: updateSettings.autoCheck ?? true, - checkIntervalHours: updateSettings.checkIntervalHours ?? 24, - }); + setFallbackState(prev => ({ + ...prev, + settings: { + autoCheck: updateSettings.autoCheck ?? true, + checkIntervalHours: updateSettings.checkIntervalHours ?? 24, + }, + })); } } catch { // Use defaults @@ -76,12 +109,13 @@ export const UpdateProvider: React.FC = ({ children }) => { // Subscribe to status updates const unsubscribe = window.localmost?.update?.onStatusChange((newStatus: UpdateStatus) => { - setStatus(newStatus); - setIsChecking(newStatus.status === 'checking'); - // Reset dismissed state when new update is available - if (newStatus.status === 'available') { - setIsDismissed(false); - } + setFallbackState(prev => ({ + ...prev, + status: newStatus, + isChecking: newStatus.status === 'checking', + // Reset dismissed state when new update is available + isDismissed: newStatus.status === 'available' ? false : prev.isDismissed, + })); }); return () => { @@ -91,17 +125,17 @@ export const UpdateProvider: React.FC = ({ children }) => { const checkForUpdates = useCallback(async () => { if (!window.localmost?.update) return; - setIsChecking(true); - setIsDismissed(false); + setFallbackState(prev => ({ ...prev, isChecking: true, isDismissed: false })); try { await window.localmost.update.check(); - setLastChecked(new Date()); + const now = new Date().toISOString(); + setFallbackState(prev => ({ ...prev, lastChecked: now })); // Clear "Up to date" message after 5 seconds - setTimeout(() => setLastChecked(null), 5000); + setTimeout(() => setFallbackState(prev => ({ ...prev, lastChecked: null })), 5000); } catch { // Error handling is done via status updates } finally { - setIsChecking(false); + setFallbackState(prev => ({ ...prev, isChecking: false })); } }, []); @@ -116,11 +150,11 @@ export const UpdateProvider: React.FC = ({ children }) => { }, []); const dismissUpdate = useCallback(() => { - setIsDismissed(true); + setFallbackState(prev => ({ ...prev, isDismissed: true })); }, []); - const setSettings = useCallback(async (newSettings: UpdateSettings) => { - setSettingsState(newSettings); + const setSettingsCallback = useCallback(async (newSettings: UpdateSettings) => { + setFallbackState(prev => ({ ...prev, settings: newSettings })); try { await window.localmost.settings.set({ updateSettings: newSettings }); } catch { @@ -131,7 +165,7 @@ export const UpdateProvider: React.FC = ({ children }) => { const value: UpdateContextValue = { status, settings, - setSettings, + setSettings: setSettingsCallback, checkForUpdates, downloadUpdate, installUpdate,