diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2bb215..4a8e971 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,17 +34,40 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Import signing certificate + if: ${{ secrets.APPLE_CERTIFICATE != '' }} + env: + CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE }} + CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + echo "$CERTIFICATE_BASE64" | base64 --decode > $RUNNER_TEMP/certificate.p12 + security import "$RUNNER_TEMP/certificate.p12" -P "$CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> $GITHUB_ENV + echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> $GITHUB_ENV + - name: Build macOS arm64 run: npm run make -- --platform darwin --arch arm64 env: - # Code signing (optional) - # APPLE_ID: ${{ secrets.APPLE_ID }} - # APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - # APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - # CSC_LINK: ${{ secrets.CSC_LINK }} - # CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_IDENTITY: ${{ secrets.APPLE_CERTIFICATE != '' && 'Developer ID Application: Andrew Chumchal (2TT36YTB8P)' || '' }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} NODE_ENV: production + - name: Clean up keychain + if: always() + run: | + if [ -n "$KEYCHAIN_PATH" ]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi + - uses: actions/upload-artifact@v4 with: name: openconduit-mac-arm64-${{ github.ref_name }} diff --git a/package-lock.json b/package-lock.json index f25968e..c07f79c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -423,6 +423,75 @@ "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz", + "integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@electron-forge/cli": { "version": "7.11.1", "resolved": "https://registry.npmjs.org/@electron-forge/cli/-/cli-7.11.1.tgz", @@ -2295,6 +2364,41 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "2.0.22", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.22.tgz", @@ -2347,6 +2451,12 @@ "node": ">= 12.13.0" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@mermaid-js/parser": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz", @@ -2714,11 +2824,16 @@ } }, "node_modules/@openconduit/core": { - "version": "1.2.0", - "resolved": "https://npm.pkg.github.com/download/@openconduit/core/1.2.0/6351ede404d3ae0cc8739df92acface5e1c215db", - "integrity": "sha512-HaT6qZbg/n7F1IQS+a+iv/gyVP2OpaYKovKoYvhNwouP8vAvbgXKIvmGu5Sy+LY/C27T8ze8QSxGG3HaLiFCjA==", + "version": "2.0.0-alpha.3", + "resolved": "https://npm.pkg.github.com/download/@openconduit/core/2.0.0-alpha.3/9f780ae6e669b9910e4ccd58fccaf201a5d6d292", + "integrity": "sha512-CITI3XhxsLGQWpbVmG64vOAq3t5CQZ4HBhiajF/HfA5FXzo0F+zHCnGye51zoRs5bUaBi+e5G6HKYT3MtwKWaA==", "license": "AGPL-3.0", "dependencies": { + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/state": "^6.6.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.0", "highlight.js": "^11.11.1", "mermaid": "^11.15.0", "react": "^19.0.0", @@ -5985,6 +6100,12 @@ "layout-base": "^1.0.0" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -15168,6 +15289,12 @@ "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", "license": "MIT" }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -16151,6 +16278,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/watchpack": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", @@ -16675,14 +16808,14 @@ }, "packages/desktop": { "name": "openconduit", - "version": "1.2.1", + "version": "2.0.0-alpha.1", "license": "AGPL-3.0-only", "dependencies": { "@anthropic-ai/foundry-sdk": "^0.2.3", "@anthropic-ai/sdk": "^0.96.0", "@google/genai": "^2.3.0", "@modelcontextprotocol/sdk": "^1.29.0", - "@openconduit/core": "^1.2.0", + "@openconduit/core": "^2.0.0-alpha.3", "@types/pdf-parse": "^1.1.5", "electron-squirrel-startup": "^1.0.1", "electron-store": "^11.0.2", diff --git a/packages/desktop/entitlements.plist b/packages/desktop/entitlements.plist new file mode 100644 index 0000000..023d773 --- /dev/null +++ b/packages/desktop/entitlements.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.allow-jit + com.apple.security.cs.allow-unsigned-executable-memory + com.apple.security.cs.disable-library-validation + com.apple.security.network.client + + diff --git a/packages/desktop/forge.config.ts b/packages/desktop/forge.config.ts index 6a67d11..2733d83 100644 --- a/packages/desktop/forge.config.ts +++ b/packages/desktop/forge.config.ts @@ -7,6 +7,8 @@ import { VitePlugin } from '@electron-forge/plugin-vite'; import { FusesPlugin } from '@electron-forge/plugin-fuses'; import { FuseV1Options, FuseVersion } from '@electron/fuses'; +const isMac = process.platform === 'darwin'; + const config: ForgeConfig = { packagerConfig: { asar: true, @@ -14,6 +16,21 @@ const config: ForgeConfig = { name: 'OpenConduit', executableName: 'openconduit', icon: 'icons/icon', // .icns on macOS, .ico on Windows, .png on Linux + ...(isMac && process.env.APPLE_IDENTITY && { + osxSign: { + identity: process.env.APPLE_IDENTITY, + optionsForFile: () => ({ + entitlements: 'entitlements.plist', + entitlementsInherit: 'entitlements.plist', + hardenedRuntime: true, + }), + }, + osxNotarize: { + appleId: process.env.APPLE_ID!, + appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD!, + teamId: process.env.APPLE_TEAM_ID!, + }, + }), }, rebuildConfig: {}, makers: [ diff --git a/packages/desktop/package.json b/packages/desktop/package.json index eb6845e..48107cf 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "openconduit", "productName": "OpenConduit", - "version": "1.2.2", + "version": "2.0.0-alpha.1", "description": "Cross-platform desktop AI chat client — OpenAI, Anthropic, LM Studio, and MCP tool servers", "main": ".vite/build/main.js", "private": true, @@ -57,7 +57,7 @@ "@anthropic-ai/sdk": "^0.96.0", "@google/genai": "^2.3.0", "@modelcontextprotocol/sdk": "^1.29.0", - "@openconduit/core": "^1.2.0", + "@openconduit/core": "^2.0.0-alpha.3", "@types/pdf-parse": "^1.1.5", "electron-squirrel-startup": "^1.0.1", "electron-store": "^11.0.2", diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 0b25838..3942b7c 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -1,5 +1,6 @@ import { BrowserWindow, ipcMain, WebContents, app, shell, dialog } from 'electron'; import fs from 'fs/promises'; +import path from 'node:path'; import { v4 as uuidv4 } from 'uuid'; import { IPC, @@ -17,7 +18,7 @@ import { RoutingConfig, RoutingDecision, } from '../shared/types'; -import { getSettings, setSettings } from './store/settings'; +import { getSettings, setSettings, settingsStore } from './store/settings'; import { connectMcpServer, disconnectMcpServer, @@ -214,6 +215,10 @@ export function registerIpcHandlers(): void { return setSettings(parsed); }); + ipcMain.handle('settings:open-file', async (): Promise => { + await shell.openPath(settingsStore.path); + }); + // ─── Routing Evaluation ──────────────────────────────────────────────────── ipcMain.handle( IPC.ROUTING_EVALUATE, @@ -251,7 +256,67 @@ export function registerIpcHandlers(): void { }); }, ); + // ─── Extensions ─────────────────────────────────────────────────────────-- // + /* + * Scan a directory for installed extensions and append results to `out`. + * Each sub-directory must contain a `manifest.json` with at minimum `id` + * and `entryPoint` fields. The full parsed manifest is forwarded to the + * renderer so the Phase 5 sandboxed loader can pre-register contributions + * without running the extension bundle first. + */ + async function scanExtensionDir( + dir: string, + out: import('../shared/types').InstalledExtensionInfo[] + ): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const manifestPath = path.join(dir, entry.name, 'manifest.json'); + try { + const raw = await fs.readFile(manifestPath, 'utf-8'); + const parsed = JSON.parse(raw) as Record & { id?: string; entryPoint?: string }; + if (!parsed.id || !parsed.entryPoint) continue; + const { entryPoint: relEntry, ...manifestRest } = parsed; + out.push({ + id: parsed.id, + name: typeof parsed.name === 'string' ? parsed.name : parsed.id, + version: typeof parsed.version === 'string' ? parsed.version : '0.0.0', + entryPoint: path.join(dir, entry.name, relEntry as string), + // Full manifest forwarded so renderer can use Phase 5 sandboxed path. + manifest: manifestRest as import('../shared/types').InstalledExtensionInfo['manifest'], + }); + } catch { + // Skip extensions with missing or invalid manifests + } + } + } + + ipcMain.handle(IPC.EXTENSIONS_GET_INSTALLED, async (): Promise => { + const results: import('../shared/types').InstalledExtensionInfo[] = []; + + // ── Production: userData/extensions/ ───────────────────────────────────── + const extensionsDir = path.join(app.getPath('userData'), 'extensions'); + try { + await scanExtensionDir(extensionsDir, results); + } catch { + // extensions/ directory doesn't exist yet — no extensions installed + } + + // ── Development: OPENCONDUIT_DEV_EXTENSIONS env var ─────────────────────── + // Set this to an absolute path containing extension sub-directories to load + // extra extensions without copying them to userData. Example: + // OPENCONDUIT_DEV_EXTENSIONS=/path/to/core/test-extensions npm start + const devDir = process.env.OPENCONDUIT_DEV_EXTENSIONS; + if (devDir) { + try { + await scanExtensionDir(devDir, results); + } catch (err) { + console.warn('[Extensions] OPENCONDUIT_DEV_EXTENSIONS scan failed:', err); + } + } + return results; + }); // ─── Backend constants (not user-configurable) ──────────────────────────── const GITHUB_REPO = 'OpenConduit/Client'; @@ -503,7 +568,9 @@ export function registerIpcHandlers(): void { continue; } + const t0 = performance.now(); const result = await callTool(serverId, tc.name, tc.input); + const durationMs = Math.round(performance.now() - t0); processedCalls.push({ ...tc, serverId, @@ -511,6 +578,7 @@ export function registerIpcHandlers(): void { result: result.result, isError: result.isError, pending: false, + durationMs, }); } @@ -555,6 +623,50 @@ export function registerIpcHandlers(): void { return { messageId }; }); + + // ─── Debug Logging ─────────────────────────────────────────────────────── + // Keep last 7 days of daily log files; auto-prune on first write of each day. + const logsDir = path.join(app.getPath('userData'), 'logs'); + let prunedToday = false; + + ipcMain.on('log:write', async (_e, entry: { + ts: number; level: string; message: string; data?: unknown; category?: string; + }) => { + try { + await fs.mkdir(logsDir, { recursive: true }); + + const date = new Date(entry.ts); + const dateStr = date.toISOString().slice(0, 10); // YYYY-MM-DD + const time = date.toTimeString().slice(0, 8) + '.' + String(date.getMilliseconds()).padStart(3, '0'); + const cat = entry.category ? ` [${entry.category}]` : ''; + const data = entry.data !== undefined ? ' ' + JSON.stringify(entry.data) : ''; + const line = `[${time}] [${entry.level.toUpperCase().padEnd(5)}]${cat} ${entry.message}${data}\n`; + + await fs.appendFile(path.join(logsDir, `debug-${dateStr}.log`), line, 'utf-8'); + + // Prune files older than 7 days (once per process lifetime) + if (!prunedToday) { + prunedToday = true; + const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; + try { + const files = await fs.readdir(logsDir); + await Promise.all( + files + .filter((f) => f.startsWith('debug-') && f.endsWith('.log')) + .map(async (f) => { + const stat = await fs.stat(path.join(logsDir, f)); + if (stat.mtimeMs < cutoff) await fs.unlink(path.join(logsDir, f)); + }), + ); + } catch { /* prune failure is non-fatal */ } + } + } catch { /* log write failure must never crash the app */ } + }); + + ipcMain.handle('log:open', async (): Promise => { + await fs.mkdir(logsDir, { recursive: true }); + await shell.openPath(logsDir); + }); } function requestApproval( diff --git a/packages/desktop/src/main/store/settings.ts b/packages/desktop/src/main/store/settings.ts index 1965b8c..93a4ccd 100644 --- a/packages/desktop/src/main/store/settings.ts +++ b/packages/desktop/src/main/store/settings.ts @@ -1,8 +1,13 @@ import Store from 'electron-store'; +import { existsSync, unlinkSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { app } from 'electron'; import { AppSettings } from '../../shared/types'; -const defaults: AppSettings = { + +const defaults = { theme: 'system', + activeThemeId: null, providers: [], mcpServers: [], defaultParameters: { @@ -18,15 +23,64 @@ const defaults: AppSettings = { aiClarifyingQuestions: false, debugMode: false, }, -}; + logging: { + provider: false, + mcp: false, + routing: false, + settings: false, + }, +} as AppSettings; + +function createSettingsStore() { + return new Store({ + name: 'openconduit-settings', + defaults, + } as any) as any; +} + +/** + * Migrate from the old encrypted store (pre-#37). + * Reads settings using the old encryption key, deletes the encrypted file, + * then returns the decrypted data so it can be written to the new plain store. + * Users keep all their settings (API keys, providers, etc.). + */ +function decryptLegacyStore(): Partial | null { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const EncStore = Store as any; + const encryptedStore = new EncStore({ + name: 'openconduit-settings', + defaults, + encryptionKey: 'openconduit-v1', + }); + return { ...(encryptedStore.store as object) } as Partial; + } catch { + return null; + } +} + +const storePath = join(app.getPath('userData'), 'openconduit-settings.json'); +let _migratedData: Partial | null = null; + +if (existsSync(storePath)) { + try { + JSON.parse(readFileSync(storePath, 'utf-8')); + } catch { + // File is not valid JSON — decrypt with old key, then delete + _migratedData = decryptLegacyStore(); + unlinkSync(storePath); + } +} -// electron-store is ESM; cast to any to work around TypeScript resolution quirks // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const settingsStore = new Store({ - name: 'openconduit-settings', - defaults, - encryptionKey: 'openconduit-v1', -} as any) as any; +export const settingsStore = createSettingsStore(); + +// Restore decrypted settings into the new plain store +if (_migratedData) { + for (const [k, v] of Object.entries(_migratedData)) { + if (v !== undefined) settingsStore.set(k, v); + } +} export function getSettings(): AppSettings { return settingsStore.store as AppSettings; diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 351fdfb..66c570a 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer } from 'electron'; +import fs from 'node:fs'; import type { AppSettings, ChatRequest, @@ -12,6 +13,7 @@ import type { FeedbackPayload, RoutingConfig, RoutingDecision, + InstalledExtensionInfo, } from './shared/types'; import { IPC } from './shared/types'; @@ -105,6 +107,8 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.invoke(IPC.SETTINGS_EXPORT, redact), importSettings: (): Promise => ipcRenderer.invoke(IPC.SETTINGS_IMPORT), + openSettingsFile: (): Promise => + ipcRenderer.invoke('settings:open-file'), }, routing: { evaluate: (params: { @@ -117,4 +121,29 @@ contextBridge.exposeInMainWorld('api', { }): Promise => ipcRenderer.invoke(IPC.ROUTING_EVALUATE, params), }, + + extensions: { + /** Returns metadata for all extensions installed in userData/extensions/. */ + getInstalled: (): Promise => + ipcRenderer.invoke(IPC.EXTENSIONS_GET_INSTALLED), + + /** + * Reads an extension's bundled JS entry point from the filesystem. + * The preload runs in Node context so it can access arbitrary file paths + * even though the renderer cannot (contextIsolation: true, nodeIntegration: false). + * The returned source is converted to a Blob URL by the renderer and + * imported as an ES module. + */ + readFile: (filePath: string): Promise => + Promise.resolve(fs.readFileSync(filePath, 'utf-8')), + }, + + log: { + /** Fire-and-forget: append an entry to the daily log file in userData/logs/. */ + write: (entry: { ts: number; level: string; message: string; data?: unknown; category?: string }): void => + ipcRenderer.send('log:write', entry), + /** Open the userData/logs/ folder in Finder / Explorer. */ + open: (): Promise => + ipcRenderer.invoke('log:open'), + }, }); diff --git a/packages/desktop/src/renderer/env.d.ts b/packages/desktop/src/renderer/env.d.ts index c667368..4f98a36 100644 --- a/packages/desktop/src/renderer/env.d.ts +++ b/packages/desktop/src/renderer/env.d.ts @@ -10,6 +10,7 @@ import type { ToolCall, UpdateInfo, FeedbackPayload, + InstalledExtensionInfo, } from '../shared/types'; type UnsubFn = () => void; @@ -52,7 +53,44 @@ declare global { config: { exportSettings: (redact: boolean) => Promise; importSettings: () => Promise; + openSettingsFile: () => Promise; }; + routing: { + evaluate: (params: { + message: string; + routerProviderId: string; + routerModel: string; + config: import('../shared/types').RoutingConfig; + originalProviderId: string; + originalModel: string; + }) => Promise; + }; + extensions: { + /** Returns metadata for all extensions installed in userData/extensions/. */ + getInstalled: () => Promise; + /** + * Reads an extension's bundled JS entry point from the filesystem. + * The preload handles filesystem access on behalf of the renderer. + */ + readFile: (filePath: string) => Promise; + }; + log: { + /** Fire-and-forget: append an entry to the daily log file in userData/logs/. */ + write: (entry: { ts: number; level: string; message: string; data?: unknown; category?: string }) => void; + /** Open the userData/logs/ folder in Finder / Explorer. */ + open: () => Promise; + }; + }; + /** + * Global SDK surface exposed for dynamically-loaded extension bundles. + * Extensions can call any registry without importing `@openconduit/core`. + */ + __openConduit?: { + extensionRegistry: typeof import('@openconduit/core').extensionRegistry; + hookRegistry: typeof import('@openconduit/core').hookRegistry; + commandRegistry: typeof import('@openconduit/core').commandRegistry; + bottomPanelRegistry: typeof import('@openconduit/core').bottomPanelRegistry; + settingsRegistry: typeof import('@openconduit/core').settingsRegistry; }; } } diff --git a/packages/desktop/src/shared/types.ts b/packages/desktop/src/shared/types.ts index 6f7f587..7e1c60c 100644 --- a/packages/desktop/src/shared/types.ts +++ b/packages/desktop/src/shared/types.ts @@ -1,3 +1,38 @@ // Types have been moved to @openconduit/core. // This stub re-exports everything so main-process code keeps working unchanged. export * from '@openconduit/core/types'; + +// ── Local augmentation ──────────────────────────────────────────────────────── +// `manifest?` was added to InstalledExtensionInfo in the core repo (Phase 5). +// Re-declare the type here until a new @openconduit/core version is published +// and the dependency is bumped. +import type { InstalledExtensionInfo as _Base } from '@openconduit/core/types'; +export type InstalledExtensionInfo = _Base & { + /** + * Pre-read contents of the extension's `manifest.json`. + * Populated by the Electron preload; enables Phase 5 sandboxed lazy activation. + */ + manifest?: { + id: string; + name: string; + version: string; + description?: string; + author?: string; + contributes?: { + activityBarItems?: Array<{ + panelId: string; + label: string; + iconSvg?: string; + order?: number; + }>; + settings?: Array<{ + key: string; + type: 'string' | 'boolean' | 'number'; + default: string | boolean | number; + title?: string; + description?: string; + }>; + }; + }; +}; + diff --git a/packages/desktop/src/styles/brand_tokens.css b/packages/desktop/src/styles/brand_tokens.css index ab88b8d..6b76925 100644 --- a/packages/desktop/src/styles/brand_tokens.css +++ b/packages/desktop/src/styles/brand_tokens.css @@ -1,11 +1,12 @@ :root { - --color-primary: #3B82F6; - --color-secondary: #8B5CF6; - --color-accent: #06B6D4; - --color-bg: #0F172A; - --color-surface: #1E293B; - --color-muted: #64748B; - --color-text: #F8FAFC; + --color-primary: #3B82F6; + --color-secondary: #8B5CF6; + --color-accent: #06B6D4; + --color-background: #0F172A; + --color-surface: #1E293B; + --color-muted: #64748B; + --color-text: #F8FAFC; + --color-border: #334155; --font-sans: 'Inter', sans-serif; --font-mono: 'JetBrains Mono', monospace; }