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;
}