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 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/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/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/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/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/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, 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 }; 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: [ {