diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1c5c12ff..21ec5496 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,6 +12,7 @@ "test": "vitest run --passWithNoTests" }, "dependencies": { + "@iarna/toml": "^2.2.5", "@open-codesign/artifacts": "workspace:*", "@open-codesign/core": "workspace:*", "@open-codesign/providers": "workspace:*", diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts new file mode 100644 index 00000000..641e19dd --- /dev/null +++ b/apps/desktop/src/main/config.ts @@ -0,0 +1,67 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import * as TOML from '@iarna/toml'; +import { CodesignError, type Config, ConfigSchema } from '@open-codesign/shared'; + +const XDG_DEFAULT = join(homedir(), '.config', 'open-codesign'); + +export function configDir(): string { + const xdg = process.env['XDG_CONFIG_HOME']; + if (xdg && xdg.length > 0) return join(xdg, 'open-codesign'); + return XDG_DEFAULT; +} + +export function configPath(): string { + return join(configDir(), 'config.toml'); +} + +export async function readConfig(): Promise { + const path = configPath(); + let raw: string; + try { + raw = await readFile(path, 'utf8'); + } catch (err) { + if (isNotFound(err)) return null; + throw new CodesignError(`Failed to read config at ${path}`, 'CONFIG_READ_FAILED', { + cause: err, + }); + } + + let parsed: unknown; + try { + parsed = TOML.parse(raw); + } catch (err) { + throw new CodesignError(`Config at ${path} is not valid TOML`, 'CONFIG_PARSE_FAILED', { + cause: err, + }); + } + + const validated = ConfigSchema.safeParse(parsed); + if (!validated.success) { + throw new CodesignError( + `Config at ${path} does not match the expected schema: ${validated.error.message}`, + 'CONFIG_SCHEMA_INVALID', + { cause: validated.error }, + ); + } + return validated.data; +} + +export async function writeConfig(config: Config): Promise { + const validated = ConfigSchema.parse(config); + const dir = configDir(); + await mkdir(dir, { recursive: true }); + const path = configPath(); + const body = TOML.stringify(validated as unknown as TOML.JsonMap); + await writeFile(path, body, { encoding: 'utf8', mode: 0o600 }); +} + +function isNotFound(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + 'code' in err && + (err as { code?: unknown }).code === 'ENOENT' + ); +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index f23cae02..09f117f7 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -5,6 +5,7 @@ import { detectProviderFromKey } from '@open-codesign/providers'; import { BRAND, CodesignError, GeneratePayload } from '@open-codesign/shared'; import { BrowserWindow, app, ipcMain, shell } from 'electron'; import { autoUpdater } from 'electron-updater'; +import { getApiKeyForProvider, loadConfigOnBoot, registerOnboardingIpc } from './onboarding-ipc'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -52,11 +53,12 @@ function registerIpcHandlers(): void { ipcMain.handle('codesign:generate', async (_e, raw: unknown) => { const payload = GeneratePayload.parse(raw); + const apiKey = getApiKeyForProvider(payload.model.provider); return generate({ prompt: payload.prompt, history: payload.history, model: payload.model, - apiKey: payload.apiKey, + apiKey, ...(payload.baseUrl !== undefined ? { baseUrl: payload.baseUrl } : {}), }); }); @@ -76,8 +78,10 @@ function setupAutoUpdater(): void { ipcMain.handle('codesign:install-update', () => autoUpdater.quitAndInstall()); } -void app.whenReady().then(() => { +void app.whenReady().then(async () => { + await loadConfigOnBoot(); registerIpcHandlers(); + registerOnboardingIpc(); setupAutoUpdater(); createWindow(); diff --git a/apps/desktop/src/main/keychain.ts b/apps/desktop/src/main/keychain.ts new file mode 100644 index 00000000..b711ece0 --- /dev/null +++ b/apps/desktop/src/main/keychain.ts @@ -0,0 +1,29 @@ +import { CodesignError } from '@open-codesign/shared'; +import { safeStorage } from 'electron'; + +export function ensureKeychainAvailable(): void { + if (!safeStorage.isEncryptionAvailable()) { + throw new CodesignError( + 'OS keychain (safeStorage) is not available. Cannot persist API keys securely.', + 'KEYCHAIN_UNAVAILABLE', + ); + } +} + +export function encryptSecret(plaintext: string): string { + ensureKeychainAvailable(); + if (plaintext.length === 0) { + throw new CodesignError('Cannot encrypt empty secret', 'KEYCHAIN_EMPTY_INPUT'); + } + const buf = safeStorage.encryptString(plaintext); + return buf.toString('base64'); +} + +export function decryptSecret(ciphertextBase64: string): string { + ensureKeychainAvailable(); + if (ciphertextBase64.length === 0) { + throw new CodesignError('Cannot decrypt empty ciphertext', 'KEYCHAIN_EMPTY_INPUT'); + } + const buf = Buffer.from(ciphertextBase64, 'base64'); + return safeStorage.decryptString(buf); +} diff --git a/apps/desktop/src/main/onboarding-ipc.ts b/apps/desktop/src/main/onboarding-ipc.ts new file mode 100644 index 00000000..6a2018e8 --- /dev/null +++ b/apps/desktop/src/main/onboarding-ipc.ts @@ -0,0 +1,157 @@ +import { type ValidateResult, pingProvider } from '@open-codesign/providers'; +import { + CodesignError, + type Config, + type OnboardingState, + type SupportedOnboardingProvider, + isSupportedOnboardingProvider, +} from '@open-codesign/shared'; +import { ipcMain } from 'electron'; +import { readConfig, writeConfig } from './config'; +import { decryptSecret, encryptSecret } from './keychain'; + +interface SaveKeyInput { + provider: SupportedOnboardingProvider; + apiKey: string; + modelPrimary: string; + modelFast: string; +} + +interface ValidateKeyInput { + provider: SupportedOnboardingProvider; + apiKey: string; + baseUrl?: string; +} + +let cachedConfig: Config | null = null; +let configLoaded = false; + +export async function loadConfigOnBoot(): Promise { + cachedConfig = await readConfig(); + configLoaded = true; +} + +export function getCachedConfig(): Config | null { + if (!configLoaded) { + throw new CodesignError('getCachedConfig called before loadConfigOnBoot', 'CONFIG_NOT_LOADED'); + } + return cachedConfig; +} + +export function getApiKeyForProvider(provider: string): string { + const cfg = getCachedConfig(); + if (cfg === null) { + throw new CodesignError('No configuration found. Complete onboarding first.', 'CONFIG_MISSING'); + } + const ref = cfg.secrets[provider as keyof typeof cfg.secrets]; + if (ref === undefined) { + throw new CodesignError( + `No API key stored for provider "${provider}". Re-run onboarding to add one.`, + 'PROVIDER_KEY_MISSING', + ); + } + return decryptSecret(ref.ciphertext); +} + +function toState(cfg: Config | null): OnboardingState { + if (cfg === null) { + return { hasKey: false, provider: null, modelPrimary: null, modelFast: null }; + } + if (!isSupportedOnboardingProvider(cfg.provider)) { + return { hasKey: false, provider: null, modelPrimary: null, modelFast: null }; + } + const ref = cfg.secrets[cfg.provider]; + if (ref === undefined) { + return { hasKey: false, provider: cfg.provider, modelPrimary: null, modelFast: null }; + } + return { + hasKey: true, + provider: cfg.provider, + modelPrimary: cfg.modelPrimary, + modelFast: cfg.modelFast, + }; +} + +function parseSaveKey(raw: unknown): SaveKeyInput { + if (typeof raw !== 'object' || raw === null) { + throw new CodesignError('save-key expects an object payload', 'IPC_BAD_INPUT'); + } + const r = raw as Record; + const provider = r['provider']; + const apiKey = r['apiKey']; + const modelPrimary = r['modelPrimary']; + const modelFast = r['modelFast']; + if (typeof provider !== 'string' || !isSupportedOnboardingProvider(provider)) { + throw new CodesignError( + `Provider "${String(provider)}" is not supported in v0.1.`, + 'PROVIDER_NOT_SUPPORTED', + ); + } + if (typeof apiKey !== 'string' || apiKey.trim().length === 0) { + throw new CodesignError('apiKey must be a non-empty string', 'IPC_BAD_INPUT'); + } + if (typeof modelPrimary !== 'string' || modelPrimary.trim().length === 0) { + throw new CodesignError('modelPrimary must be a non-empty string', 'IPC_BAD_INPUT'); + } + if (typeof modelFast !== 'string' || modelFast.trim().length === 0) { + throw new CodesignError('modelFast must be a non-empty string', 'IPC_BAD_INPUT'); + } + return { provider, apiKey, modelPrimary, modelFast }; +} + +function parseValidateKey(raw: unknown): ValidateKeyInput { + if (typeof raw !== 'object' || raw === null) { + throw new CodesignError('validate-key expects an object payload', 'IPC_BAD_INPUT'); + } + const r = raw as Record; + const provider = r['provider']; + const apiKey = r['apiKey']; + const baseUrl = r['baseUrl']; + if (typeof provider !== 'string') { + throw new CodesignError('provider must be a string', 'IPC_BAD_INPUT'); + } + if (typeof apiKey !== 'string' || apiKey.trim().length === 0) { + throw new CodesignError('apiKey must be a non-empty string', 'IPC_BAD_INPUT'); + } + if (!isSupportedOnboardingProvider(provider)) { + throw new CodesignError( + `Provider "${provider}" is not supported in v0.1. Only anthropic, openai, openrouter.`, + 'PROVIDER_NOT_SUPPORTED', + ); + } + const out: ValidateKeyInput = { provider, apiKey }; + if (typeof baseUrl === 'string' && baseUrl.length > 0) out.baseUrl = baseUrl; + return out; +} + +export function registerOnboardingIpc(): void { + ipcMain.handle('onboarding:get-state', (): OnboardingState => toState(getCachedConfig())); + + ipcMain.handle('onboarding:validate-key', async (_e, raw: unknown): Promise => { + const input = parseValidateKey(raw); + return pingProvider(input.provider, input.apiKey, input.baseUrl); + }); + + ipcMain.handle('onboarding:save-key', async (_e, raw: unknown): Promise => { + const input = parseSaveKey(raw); + const ciphertext = encryptSecret(input.apiKey); + const next: Config = { + version: 1, + provider: input.provider, + modelPrimary: input.modelPrimary, + modelFast: input.modelFast, + secrets: { + ...(cachedConfig?.secrets ?? {}), + [input.provider]: { ciphertext }, + }, + }; + await writeConfig(next); + cachedConfig = next; + configLoaded = true; + return toState(cachedConfig); + }); + + ipcMain.handle('onboarding:skip', async (): Promise => { + return toState(cachedConfig); + }); +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 1c32bf40..a86afc68 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,6 +1,21 @@ -import type { ChatMessage, ModelRef } from '@open-codesign/shared'; +import type { + ChatMessage, + ModelRef, + OnboardingState, + SupportedOnboardingProvider, +} from '@open-codesign/shared'; import { contextBridge, ipcRenderer } from 'electron'; +export interface ValidateKeyResult { + ok: true; + modelCount: number; +} +export interface ValidateKeyError { + ok: false; + code: '401' | '402' | '429' | 'network'; + message: string; +} + const api = { detectProvider: (key: string) => ipcRenderer.invoke('codesign:detect-provider', key) as Promise, @@ -8,7 +23,6 @@ const api = { prompt: string; history: ChatMessage[]; model: ModelRef; - apiKey: string; baseUrl?: string; }) => ipcRenderer.invoke('codesign:generate', payload), checkForUpdates: () => ipcRenderer.invoke('codesign:check-for-updates'), @@ -19,6 +33,24 @@ const api = { ipcRenderer.on('codesign:update-available', listener); return () => ipcRenderer.removeListener('codesign:update-available', listener); }, + onboarding: { + getState: () => ipcRenderer.invoke('onboarding:get-state') as Promise, + validateKey: (input: { + provider: SupportedOnboardingProvider; + apiKey: string; + baseUrl?: string; + }) => + ipcRenderer.invoke('onboarding:validate-key', input) as Promise< + ValidateKeyResult | ValidateKeyError + >, + saveKey: (input: { + provider: SupportedOnboardingProvider; + apiKey: string; + modelPrimary: string; + modelFast: string; + }) => ipcRenderer.invoke('onboarding:save-key', input) as Promise, + skip: () => ipcRenderer.invoke('onboarding:skip') as Promise, + }, }; contextBridge.exposeInMainWorld('codesign', api); diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index e761430c..7385a6ed 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -2,7 +2,8 @@ import { buildSrcdoc } from '@open-codesign/runtime'; import { BUILTIN_DEMOS } from '@open-codesign/templates'; import { Button } from '@open-codesign/ui'; import { Send, Sparkles } from 'lucide-react'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { Onboarding } from './onboarding'; import { useCodesignStore } from './store'; export function App() { @@ -10,8 +11,15 @@ export function App() { const previewHtml = useCodesignStore((s) => s.previewHtml); const isGenerating = useCodesignStore((s) => s.isGenerating); const sendPrompt = useCodesignStore((s) => s.sendPrompt); + const config = useCodesignStore((s) => s.config); + const configLoaded = useCodesignStore((s) => s.configLoaded); + const loadConfig = useCodesignStore((s) => s.loadConfig); const [prompt, setPrompt] = useState(''); + useEffect(() => { + void loadConfig(); + }, [loadConfig]); + function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!prompt.trim() || isGenerating) return; @@ -19,6 +27,18 @@ export function App() { setPrompt(''); } + if (!configLoaded) { + return ( +
+ Loading… +
+ ); + } + + if (config === null || !config.hasKey) { + return ; + } + return (