From 74f9cdcb03b4b458b7cdba37d8f50ff4902343ec Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Sat, 18 Apr 2026 21:27:04 +0800 Subject: [PATCH 1/6] feat(desktop): settings tabs with multi-provider management and preferences persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements all four settings tabs (Models, Appearance, Storage, Advanced) with full persistence and correct error handling. Key changes: - Settings UI: four-tab panel (Models / Appearance / Storage / Advanced) with provider cards, add/delete/activate flows, model selector, path viewer, reset - toProviderRows: soft-fail on safeStorage decryption — returns error:'decryption_failed' row instead of throwing; UI shows red badge + 'Re-enter key' button - preferences-ipc.ts: new IPC module persisting updateChannel and generationTimeoutSec to ~/.config/open-codesign/preferences.json (schemaVersion:1) - locale-ipc.ts and preferences-ipc.ts registered in main/index.ts - preload: settings, preferences, and locale namespaces exposed to renderer - AppearanceTab: language select reads/writes via locale:get-current / locale:set - AdvancedTab: update channel and generation timeout read/write via preferences IPC - ActiveModelSelector: 400ms debounce with proper useEffect cleanup on unmount - delete-provider: fix ghost active provider — when all providers removed, write tombstone config (empty secrets) instead of falling back to hardcoded 'openai'; re-adding a new provider correctly auto-activates it - provider-settings.ts and onboarding-ipc.ts: use electron-runtime import pattern - Tests: 8 total (5 new), covering decryption-failed row, masked-key row, ghost-provider auto-activate, provider preservation, assertProviderHasStoredSecret Signed-off-by: hqhq1025 <1506751656@qq.com> --- apps/desktop/package.json | 1 - apps/desktop/src/main/design-system.ts | 229 ---- apps/desktop/src/main/exporter-ipc.ts | 3 +- apps/desktop/src/main/index.ts | 33 +- apps/desktop/src/main/keychain.ts | 2 +- apps/desktop/src/main/locale-ipc.ts | 2 +- apps/desktop/src/main/logger.ts | 2 +- apps/desktop/src/main/onboarding-ipc.ts | 218 +++- apps/desktop/src/main/preferences-ipc.ts | 97 ++ apps/desktop/src/main/prompt-context.test.ts | 54 - apps/desktop/src/main/prompt-context.ts | 222 ---- .../src/main/provider-settings.test.ts | 94 ++ apps/desktop/src/main/provider-settings.ts | 95 ++ .../desktop/src/main/storage-settings.test.ts | 20 + apps/desktop/src/main/storage-settings.ts | 19 + apps/desktop/src/preload/index.ts | 58 +- apps/desktop/src/renderer/src/App.tsx | 8 +- .../src/components/CanvasErrorBar.tsx | 16 +- .../src/components/CommandPalette.tsx | 60 +- .../src/components/InlineCommentComposer.tsx | 83 -- .../src/components/LanguageToggle.tsx | 41 - .../src/components/PreviewPane.test.ts | 13 - .../src/components/PreviewToolbar.tsx | 43 +- .../renderer/src/components/Settings.test.ts | 34 + .../src/renderer/src/components/Settings.tsx | 1041 ++++++++++++++++- .../src/renderer/src/components/Sidebar.tsx | 8 +- .../src/renderer/src/components/TopBar.tsx | 22 +- apps/desktop/src/renderer/src/main.tsx | 19 +- .../renderer/src/onboarding/ChooseModel.tsx | 126 +- .../src/renderer/src/onboarding/Welcome.tsx | 4 +- .../src/renderer/src/onboarding/index.tsx | 16 +- .../src/renderer/src/preview/EmptyState.tsx | 38 +- .../src/renderer/src/preview/ErrorState.tsx | 12 +- apps/desktop/src/renderer/src/store.ts | 186 +-- packages/core/src/generate.test.ts | 164 +-- packages/core/src/index.ts | 271 +---- packages/i18n/src/locales/en.json | 61 +- packages/i18n/src/locales/zh-CN.json | 61 +- packages/providers/src/index.test.ts | 107 -- packages/providers/src/index.ts | 120 +- packages/shared/src/config.test.ts | 29 - packages/shared/src/config.ts | 25 - packages/shared/src/index.ts | 25 - .../templates/src/system/design-generator.md | 64 +- packages/templates/src/system/index.ts | 17 +- pnpm-lock.yaml | 3 - 46 files changed, 1824 insertions(+), 2042 deletions(-) delete mode 100644 apps/desktop/src/main/design-system.ts create mode 100644 apps/desktop/src/main/preferences-ipc.ts delete mode 100644 apps/desktop/src/main/prompt-context.test.ts delete mode 100644 apps/desktop/src/main/prompt-context.ts create mode 100644 apps/desktop/src/main/provider-settings.test.ts create mode 100644 apps/desktop/src/main/provider-settings.ts create mode 100644 apps/desktop/src/main/storage-settings.test.ts create mode 100644 apps/desktop/src/main/storage-settings.ts delete mode 100644 apps/desktop/src/renderer/src/components/InlineCommentComposer.tsx delete mode 100644 apps/desktop/src/renderer/src/components/LanguageToggle.tsx delete mode 100644 apps/desktop/src/renderer/src/components/PreviewPane.test.ts create mode 100644 apps/desktop/src/renderer/src/components/Settings.test.ts delete mode 100644 packages/providers/src/index.test.ts delete mode 100644 packages/shared/src/config.test.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ea1a3729..46fbf9e5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -16,7 +16,6 @@ "@open-codesign/artifacts": "workspace:*", "@open-codesign/core": "workspace:*", "@open-codesign/exporters": "workspace:*", - "@open-codesign/i18n": "workspace:*", "@open-codesign/providers": "workspace:*", "@open-codesign/runtime": "workspace:*", "@open-codesign/shared": "workspace:*", diff --git a/apps/desktop/src/main/design-system.ts b/apps/desktop/src/main/design-system.ts deleted file mode 100644 index 114892d6..00000000 --- a/apps/desktop/src/main/design-system.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { Dirent } from 'node:fs'; -import { readFile, readdir } from 'node:fs/promises'; -import { basename, extname, join, relative } from 'node:path'; -import { - STORED_DESIGN_SYSTEM_SCHEMA_VERSION, - type StoredDesignSystem, -} from '@open-codesign/shared'; - -const IGNORED_DIRS = new Set([ - '.git', - '.idea', - '.next', - '.turbo', - '.vscode', - 'build', - 'coverage', - 'dist', - 'node_modules', - 'out', -]); - -const CANDIDATE_EXTS = new Set([ - '.css', - '.scss', - '.sass', - '.less', - '.js', - '.jsx', - '.ts', - '.tsx', - '.json', - '.md', -]); - -const PRIORITY_PATTERNS = [ - /tailwind\.config/i, - /tokens?/i, - /theme/i, - /brand/i, - /palette/i, - /typography/i, - /global\.(css|scss|sass|less)$/i, - /variables?\.(css|scss|sass|less)$/i, -]; - -const MAX_FILES = 160; -const MAX_SELECTED_FILES = 12; -const MAX_FILE_CHARS = 32_000; - -interface CandidateFile { - fullPath: string; - relativePath: string; - score: number; -} - -function pushUnique(target: string[], value: string, max: number): void { - if (!value || target.includes(value) || target.length >= max) return; - target.push(value); -} - -function cleanValue(value: string): string { - return value - .replace(/\s+/g, ' ') - .replace(/["'`,]/g, '') - .trim(); -} - -function scoreCandidate(relativePath: string): number { - const fileName = basename(relativePath); - let score = 1; - for (const pattern of PRIORITY_PATTERNS) { - if (pattern.test(relativePath) || pattern.test(fileName)) score += 20; - } - if (/\.(css|scss|sass|less)$/i.test(fileName)) score += 8; - if (/tailwind/i.test(relativePath)) score += 8; - if (/src\//i.test(relativePath)) score += 4; - return score; -} - -async function collectCandidateFiles( - rootPath: string, - dirPath: string, - files: CandidateFile[], -): Promise { - if (files.length >= MAX_FILES) return; - - let entries: Dirent[]; - try { - entries = await readdir(dirPath, { withFileTypes: true, encoding: 'utf8' }); - } catch { - return; - } - - for (const entry of entries) { - if (files.length >= MAX_FILES) return; - const fullPath = join(dirPath, entry.name); - if (entry.isDirectory()) { - if (!IGNORED_DIRS.has(entry.name)) { - await collectCandidateFiles(rootPath, fullPath, files); - } - continue; - } - if (!entry.isFile()) continue; - const extension = extname(entry.name).toLowerCase(); - if (!CANDIDATE_EXTS.has(extension) && !/tailwind\.config/i.test(entry.name)) continue; - const relativePath = relative(rootPath, fullPath).replace(/\\/g, '/'); - files.push({ fullPath, relativePath, score: scoreCandidate(relativePath) }); - } -} - -function collectCssVarValues( - raw: string, - colors: string[], - spacing: string[], - radius: string[], - shadows: string[], -): void { - for (const match of raw.matchAll(/--([a-z0-9-]+)\s*:\s*([^;}{\n]+)/gi)) { - const name = match[1]?.toLowerCase() ?? ''; - const value = cleanValue(match[2] ?? ''); - if (!value) continue; - if (/color|accent|surface|text|brand|primary|secondary/.test(name)) - pushUnique(colors, value, 24); - if (/space|spacing|gap|padding|margin/.test(name)) pushUnique(spacing, value, 16); - if (/radius|rounded/.test(name)) pushUnique(radius, value, 16); - if (/shadow/.test(name)) pushUnique(shadows, value, 16); - } -} - -function collectLooseValues( - raw: string, - colors: string[], - fonts: string[], - spacing: string[], - radius: string[], - shadows: string[], -): void { - for (const match of raw.matchAll(/#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/gi)) { - pushUnique(colors, cleanValue(match[0] ?? ''), 24); - } - - for (const match of raw.matchAll(/(?:font-family|fontFamily)[^:=]*[:=]\s*([^\n;]+)/gi)) { - const value = cleanValue(match[1] ?? ''); - for (const part of value.split(',')) pushUnique(fonts, cleanValue(part), 16); - } - - for (const match of raw.matchAll( - /(?:spacing|space|gap|padding|margin)[^:=\n]*[:=]\s*([^\n,;]+)/gi, - )) { - pushUnique(spacing, cleanValue(match[1] ?? ''), 16); - } - - for (const match of raw.matchAll(/(?:radius|rounded|borderRadius)[^:=\n]*[:=]\s*([^\n,;]+)/gi)) { - pushUnique(radius, cleanValue(match[1] ?? ''), 16); - } - - for (const match of raw.matchAll(/(?:shadow|boxShadow)[^:=\n]*[:=]\s*([^\n,;]+)/gi)) { - pushUnique(shadows, cleanValue(match[1] ?? ''), 16); - } -} - -function buildSummary( - snapshot: Omit, -): string { - const repoLabel = basename(snapshot.rootPath); - const parts = [ - `Scanned ${snapshot.sourceFiles.length} likely design-system files under ${repoLabel}.`, - ]; - if (snapshot.colors.length > 0) - parts.push(`Color language: ${snapshot.colors.slice(0, 5).join(', ')}.`); - if (snapshot.fonts.length > 0) - parts.push(`Typography: ${snapshot.fonts.slice(0, 4).join(', ')}.`); - if (snapshot.spacing.length > 0) - parts.push(`Spacing cues: ${snapshot.spacing.slice(0, 4).join(', ')}.`); - if (snapshot.radius.length > 0) - parts.push(`Corner radius cues: ${snapshot.radius.slice(0, 4).join(', ')}.`); - if (snapshot.shadows.length > 0) - parts.push(`Shadow cues: ${snapshot.shadows.slice(0, 4).join(', ')}.`); - if (parts.length === 1) { - parts.push( - 'No strong structured tokens were extracted, so lean on the referenced styling files and keep the output conservative and cohesive.', - ); - } - return parts.join(' '); -} - -export async function scanDesignSystem(rootPath: string): Promise { - const candidates: CandidateFile[] = []; - await collectCandidateFiles(rootPath, rootPath, candidates); - - const selected = candidates - .sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath)) - .slice(0, MAX_SELECTED_FILES); - - const colors: string[] = []; - const fonts: string[] = []; - const spacing: string[] = []; - const radius: string[] = []; - const shadows: string[] = []; - - for (const file of selected) { - let raw = ''; - try { - raw = await readFile(file.fullPath, 'utf8'); - } catch { - continue; - } - const snippet = raw.slice(0, MAX_FILE_CHARS); - collectCssVarValues(snippet, colors, spacing, radius, shadows); - collectLooseValues(snippet, colors, fonts, spacing, radius, shadows); - } - - const baseSnapshot = { - rootPath, - sourceFiles: selected.map((file) => file.relativePath), - colors, - fonts, - spacing, - radius, - shadows, - }; - - return { - schemaVersion: STORED_DESIGN_SYSTEM_SCHEMA_VERSION, - ...baseSnapshot, - summary: buildSummary(baseSnapshot), - extractedAt: new Date().toISOString(), - }; -} diff --git a/apps/desktop/src/main/exporter-ipc.ts b/apps/desktop/src/main/exporter-ipc.ts index 15b00eea..53a06cd7 100644 --- a/apps/desktop/src/main/exporter-ipc.ts +++ b/apps/desktop/src/main/exporter-ipc.ts @@ -1,7 +1,6 @@ import { type ExporterFormat, exportArtifact } from '@open-codesign/exporters'; import { CodesignError } from '@open-codesign/shared'; -import type { BrowserWindow } from 'electron'; -import { dialog, ipcMain } from './electron-runtime'; +import { type BrowserWindow, dialog, ipcMain } from 'electron'; const FORMAT_FILTERS: Record = { html: [{ name: 'HTML', extensions: ['html'] }], diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 5f0c0d41..9fc961c8 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,7 +1,6 @@ -import { stat } from 'node:fs/promises'; -import { basename, dirname, join } from 'node:path'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { applyComment, generate } from '@open-codesign/core'; +import { generate } from '@open-codesign/core'; import { detectProviderFromKey } from '@open-codesign/providers'; import { ApplyCommentPayload, @@ -13,8 +12,7 @@ import { } from '@open-codesign/shared'; import type { BrowserWindow as ElectronBrowserWindow } from 'electron'; import { autoUpdater } from 'electron-updater'; -import { scanDesignSystem } from './design-system'; -import { BrowserWindow, app, dialog, ipcMain, shell } from './electron-runtime'; +import { BrowserWindow, app, ipcMain, shell } from './electron-runtime'; import { registerExporterIpc } from './exporter-ipc'; import { cancelGenerationRequest } from './generation-ipc'; import { registerLocaleIpc } from './locale-ipc'; @@ -22,13 +20,10 @@ import { getLogPath, getLogger, initLogger } from './logger'; import { getApiKeyForProvider, getBaseUrlForProvider, - getCachedConfig, - getOnboardingState, loadConfigOnBoot, registerOnboardingIpc, - setDesignSystem, } from './onboarding-ipc'; -import { preparePromptContext } from './prompt-context'; +import { registerPreferencesIpc } from './preferences-ipc'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -41,7 +36,6 @@ function createWindow(): void { height: 820, minWidth: 960, minHeight: 640, - autoHideMenuBar: process.platform !== 'darwin', titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', backgroundColor: BRAND.backgroundColor, show: false, @@ -139,25 +133,14 @@ function registerIpcHandlers(): void { const apiKey = getApiKeyForProvider(payload.model.provider); const storedBaseUrl = getBaseUrlForProvider(payload.model.provider); const baseUrl = payload.baseUrl ?? storedBaseUrl; - const cfg = getCachedConfig(); - const promptContext = await preparePromptContext({ - attachments: payload.attachments, - referenceUrl: payload.referenceUrl, - designSystem: cfg?.designSystem ?? null, - }); - logIpc.info('generate', { id, provider: payload.model.provider, modelId: payload.model.modelId, promptLen: payload.prompt.length, historyLen: payload.history.length, - attachmentCount: payload.attachments.length, - hasReferenceUrl: payload.referenceUrl !== undefined, - hasDesignSystem: promptContext.designSystem !== null, baseUrl: baseUrl ?? '', }); - const t0 = Date.now(); try { const result = await generate({ @@ -165,17 +148,14 @@ function registerIpcHandlers(): void { history: payload.history, model: payload.model, apiKey, - attachments: promptContext.attachments, - referenceUrl: promptContext.referenceUrl, - designSystem: promptContext.designSystem ?? null, ...(baseUrl !== undefined ? { baseUrl } : {}), signal: controller.signal, }); logIpc.info('generate.ok', { id, ms: Date.now() - t0, - artifacts: result.artifacts.length, - cost: result.costUsd, + artifacts: (result as { artifacts?: unknown[] }).artifacts?.length ?? 0, + cost: (result as { costUsd?: number }).costUsd, }); return result; } catch (err) { @@ -353,6 +333,7 @@ void app.whenReady().then(async () => { registerIpcHandlers(); registerLocaleIpc(); registerOnboardingIpc(); + registerPreferencesIpc(); registerExporterIpc(() => mainWindow); setupAutoUpdater(); createWindow(); diff --git a/apps/desktop/src/main/keychain.ts b/apps/desktop/src/main/keychain.ts index f6ecdaf3..b711ece0 100644 --- a/apps/desktop/src/main/keychain.ts +++ b/apps/desktop/src/main/keychain.ts @@ -1,5 +1,5 @@ import { CodesignError } from '@open-codesign/shared'; -import { safeStorage } from './electron-runtime'; +import { safeStorage } from 'electron'; export function ensureKeychainAvailable(): void { if (!safeStorage.isEncryptionAvailable()) { diff --git a/apps/desktop/src/main/locale-ipc.ts b/apps/desktop/src/main/locale-ipc.ts index 1e664dee..ddd1917f 100644 --- a/apps/desktop/src/main/locale-ipc.ts +++ b/apps/desktop/src/main/locale-ipc.ts @@ -14,7 +14,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; -import { app, ipcMain } from './electron-runtime'; +import { app, ipcMain } from 'electron'; const CONFIG_DIR = join(homedir(), '.config', 'open-codesign'); const LOCALE_FILE = join(CONFIG_DIR, 'locale.json'); diff --git a/apps/desktop/src/main/logger.ts b/apps/desktop/src/main/logger.ts index f0696043..b5c0cd87 100644 --- a/apps/desktop/src/main/logger.ts +++ b/apps/desktop/src/main/logger.ts @@ -1,6 +1,6 @@ import { join } from 'node:path'; +import { app } from 'electron'; import log from 'electron-log/main'; -import { app } from './electron-runtime'; /** * Centralized logger for the main + preload + renderer processes. diff --git a/apps/desktop/src/main/onboarding-ipc.ts b/apps/desktop/src/main/onboarding-ipc.ts index d633bf6f..efbde7aa 100644 --- a/apps/desktop/src/main/onboarding-ipc.ts +++ b/apps/desktop/src/main/onboarding-ipc.ts @@ -3,14 +3,20 @@ import { CodesignError, type Config, type OnboardingState, - StoredDesignSystem, - type StoredDesignSystem as StoredDesignSystemValue, type SupportedOnboardingProvider, isSupportedOnboardingProvider, } from '@open-codesign/shared'; -import { readConfig, writeConfig } from './config'; -import { ipcMain } from './electron-runtime'; +import { configDir, configPath, readConfig, writeConfig } from './config'; +import { ipcMain, shell } from './electron-runtime'; import { decryptSecret, encryptSecret } from './keychain'; +import { getLogPath } from './logger'; +import { + type ProviderRow, + assertProviderHasStoredSecret, + getAddProviderDefaults, + toProviderRows, +} from './provider-settings'; +import { buildAppPaths } from './storage-settings'; interface SaveKeyInput { provider: SupportedOnboardingProvider; @@ -26,6 +32,8 @@ interface ValidateKeyInput { baseUrl?: string; } +export type { ProviderRow } from './provider-settings'; + let cachedConfig: Config | null = null; let configLoaded = false; @@ -63,26 +71,12 @@ export function getBaseUrlForProvider(provider: string): string | undefined { return ref?.baseUrl; } -export function toState(cfg: Config | null): OnboardingState { +function toState(cfg: Config | null): OnboardingState { if (cfg === null) { - return { - hasKey: false, - provider: null, - modelPrimary: null, - modelFast: null, - baseUrl: null, - designSystem: null, - }; + return { hasKey: false, provider: null, modelPrimary: null, modelFast: null, baseUrl: null }; } if (!isSupportedOnboardingProvider(cfg.provider)) { - return { - hasKey: false, - provider: null, - modelPrimary: null, - modelFast: null, - baseUrl: null, - designSystem: cfg.designSystem ?? null, - }; + return { hasKey: false, provider: null, modelPrimary: null, modelFast: null, baseUrl: null }; } const ref = cfg.secrets[cfg.provider]; if (ref === undefined) { @@ -92,7 +86,6 @@ export function toState(cfg: Config | null): OnboardingState { modelPrimary: null, modelFast: null, baseUrl: null, - designSystem: cfg.designSystem ?? null, }; } return { @@ -101,35 +94,7 @@ export function toState(cfg: Config | null): OnboardingState { modelPrimary: cfg.modelPrimary, modelFast: cfg.modelFast, baseUrl: cfg.baseUrls?.[cfg.provider]?.baseUrl ?? null, - designSystem: cfg.designSystem ?? null, - }; -} - -export function getOnboardingState(): OnboardingState { - return toState(getCachedConfig()); -} - -export async function setDesignSystem( - designSystem: StoredDesignSystemValue | null, -): Promise { - const cfg = getCachedConfig(); - if (cfg === null) { - throw new CodesignError( - 'Cannot save a design system before onboarding has completed.', - 'CONFIG_MISSING', - ); - } - const next: Config = { - ...cfg, - ...(designSystem ? { designSystem: StoredDesignSystem.parse(designSystem) } : {}), }; - if (designSystem === null) { - next.designSystem = undefined; - } - await writeConfig(next); - cachedConfig = next; - configLoaded = true; - return toState(cachedConfig); } function parseSaveKey(raw: unknown): SaveKeyInput { @@ -221,7 +186,6 @@ export function registerOnboardingIpc(): void { [input.provider]: { ciphertext }, }, baseUrls: nextBaseUrls, - ...(cachedConfig?.designSystem ? { designSystem: cachedConfig.designSystem } : {}), }; await writeConfig(next); cachedConfig = next; @@ -232,4 +196,156 @@ export function registerOnboardingIpc(): void { ipcMain.handle('onboarding:skip', async (): Promise => { return toState(cachedConfig); }); + + // ── Settings: provider management ────────────────────────────────────────── + + ipcMain.handle('settings:list-providers', (): ProviderRow[] => { + return toProviderRows(getCachedConfig(), decryptSecret); + }); + + ipcMain.handle('settings:add-provider', async (_e, raw: unknown): Promise => { + const input = parseSaveKey(raw); + const ciphertext = encryptSecret(input.apiKey); + const nextBaseUrls = { ...(cachedConfig?.baseUrls ?? {}) }; + if (input.baseUrl !== undefined) { + nextBaseUrls[input.provider] = { baseUrl: input.baseUrl }; + } else { + delete nextBaseUrls[input.provider]; + } + const nextDefaults = getAddProviderDefaults(cachedConfig, input); + const next: Config = { + version: 1, + provider: nextDefaults.activeProvider, + modelPrimary: nextDefaults.modelPrimary, + modelFast: nextDefaults.modelFast, + secrets: { + ...(cachedConfig?.secrets ?? {}), + [input.provider]: { ciphertext }, + }, + baseUrls: nextBaseUrls, + }; + await writeConfig(next); + cachedConfig = next; + return toProviderRows(cachedConfig, decryptSecret); + }); + + ipcMain.handle('settings:delete-provider', async (_e, raw: unknown): Promise => { + if (typeof raw !== 'string' || !isSupportedOnboardingProvider(raw)) { + throw new CodesignError('delete-provider expects a provider string', 'IPC_BAD_INPUT'); + } + const cfg = getCachedConfig(); + if (cfg === null) return []; + const nextSecrets = { ...cfg.secrets }; + delete nextSecrets[raw]; + const nextBaseUrls = { ...(cfg.baseUrls ?? {}) }; + delete nextBaseUrls[raw]; + // If we deleted the active provider, pick the first remaining one with a secret. + // If nothing remains, write a tombstone config so onboarding triggers again. + const remaining = Object.keys(nextSecrets).filter(isSupportedOnboardingProvider); + // Determine the next active provider: keep current if untouched and valid, + // otherwise fall back to the first remaining supported provider. + const currentIsSupported = isSupportedOnboardingProvider(cfg.provider); + const keepCurrent = cfg.provider !== raw && currentIsSupported; + let nextActive: SupportedOnboardingProvider; + if (keepCurrent) { + nextActive = cfg.provider as SupportedOnboardingProvider; + } else if (remaining.length > 0) { + nextActive = remaining[0] as SupportedOnboardingProvider; + } else { + // No providers left — write a tombstone config so onboarding triggers again. + const emptyNext: Config = { + version: 1, + provider: cfg.provider, + modelPrimary: cfg.modelPrimary, + modelFast: cfg.modelFast, + secrets: {}, + baseUrls: {}, + }; + await writeConfig(emptyNext); + cachedConfig = emptyNext; + return toProviderRows(cachedConfig, decryptSecret); + } + const next: Config = { + version: 1, + provider: nextActive, + modelPrimary: cfg.modelPrimary, + modelFast: cfg.modelFast, + secrets: nextSecrets, + baseUrls: nextBaseUrls, + }; + await writeConfig(next); + cachedConfig = next; + return toProviderRows(cachedConfig, decryptSecret); + }); + + ipcMain.handle( + 'settings:set-active-provider', + async (_e, raw: unknown): Promise => { + if (typeof raw !== 'object' || raw === null) { + throw new CodesignError('set-active-provider expects an object', 'IPC_BAD_INPUT'); + } + const r = raw as Record; + const provider = r['provider']; + const modelPrimary = r['modelPrimary']; + const modelFast = r['modelFast']; + if (typeof provider !== 'string' || !isSupportedOnboardingProvider(provider)) { + throw new CodesignError('provider must be a supported provider 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'); + } + const cfg = getCachedConfig(); + if (cfg === null) { + throw new CodesignError('No configuration found', 'CONFIG_MISSING'); + } + assertProviderHasStoredSecret(cfg, provider); + const next: Config = { + ...cfg, + provider, + modelPrimary, + modelFast, + }; + await writeConfig(next); + cachedConfig = next; + return toState(cachedConfig); + }, + ); + + // ── Settings: storage helpers ─────────────────────────────────────────────── + + ipcMain.handle('settings:get-paths', () => + buildAppPaths(configPath(), getLogPath(), configDir()), + ); + + ipcMain.handle('settings:open-folder', async (_e, raw: unknown) => { + if (typeof raw !== 'string') { + throw new CodesignError('open-folder expects a path string', 'IPC_BAD_INPUT'); + } + const error = await shell.openPath(raw); + if (error) { + throw new CodesignError(`Could not open ${raw}: ${error}`, 'OPEN_PATH_FAILED'); + } + }); + + ipcMain.handle('settings:reset-onboarding', async (): Promise => { + const cfg = getCachedConfig(); + if (cfg === null) return; + // Clear secrets so onboarding flow triggers again on next load. + const next: Config = { + ...cfg, + secrets: {}, + }; + await writeConfig(next); + cachedConfig = next; + }); + + // ── Settings: appearance / advanced ──────────────────────────────────────── + + ipcMain.handle('settings:toggle-devtools', (_e) => { + // We need the webContents reference — the event sender is the renderer. + _e.sender.toggleDevTools(); + }); } diff --git a/apps/desktop/src/main/preferences-ipc.ts b/apps/desktop/src/main/preferences-ipc.ts new file mode 100644 index 00000000..28187e78 --- /dev/null +++ b/apps/desktop/src/main/preferences-ipc.ts @@ -0,0 +1,97 @@ +/** + * User preferences IPC handlers (main process). + * + * Persists non-provider, non-locale preferences to + * `~/.config/open-codesign/preferences.json`. Kept separate from config.toml + * so it can be read quickly at boot before the TOML loader finishes. + * + * Schema: { schemaVersion: 1, updateChannel: 'stable'|'beta', generationTimeoutSec: number } + */ + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { CodesignError } from '@open-codesign/shared'; +import { ipcMain } from 'electron'; + +const CONFIG_DIR = join(homedir(), '.config', 'open-codesign'); +const PREFS_FILE = join(CONFIG_DIR, 'preferences.json'); +const SCHEMA_VERSION = 1; + +export type UpdateChannel = 'stable' | 'beta'; + +export interface Preferences { + updateChannel: UpdateChannel; + generationTimeoutSec: number; +} + +interface PreferencesFile extends Preferences { + schemaVersion: number; +} + +const DEFAULTS: Preferences = { + updateChannel: 'stable', + generationTimeoutSec: 120, +}; + +async function readPersisted(): Promise { + try { + const raw = await readFile(PREFS_FILE, 'utf8'); + const parsed = JSON.parse(raw) as Partial; + return { + updateChannel: + parsed.updateChannel === 'stable' || parsed.updateChannel === 'beta' + ? parsed.updateChannel + : DEFAULTS.updateChannel, + generationTimeoutSec: + typeof parsed.generationTimeoutSec === 'number' && parsed.generationTimeoutSec > 0 + ? parsed.generationTimeoutSec + : DEFAULTS.generationTimeoutSec, + }; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { ...DEFAULTS }; + console.warn(`[preferences-ipc] failed to read ${PREFS_FILE}:`, err); + return { ...DEFAULTS }; + } +} + +async function writePersisted(prefs: Preferences): Promise { + await mkdir(dirname(PREFS_FILE), { recursive: true }); + const payload: PreferencesFile = { schemaVersion: SCHEMA_VERSION, ...prefs }; + await writeFile(PREFS_FILE, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); +} + +function parsePreferences(raw: unknown): Partial { + if (typeof raw !== 'object' || raw === null) { + throw new CodesignError('preferences:update expects an object', 'IPC_BAD_INPUT'); + } + const r = raw as Record; + const out: Partial = {}; + if (r['updateChannel'] !== undefined) { + if (r['updateChannel'] !== 'stable' && r['updateChannel'] !== 'beta') { + throw new CodesignError('updateChannel must be "stable" or "beta"', 'IPC_BAD_INPUT'); + } + out.updateChannel = r['updateChannel'] as UpdateChannel; + } + if (r['generationTimeoutSec'] !== undefined) { + if (typeof r['generationTimeoutSec'] !== 'number' || r['generationTimeoutSec'] <= 0) { + throw new CodesignError('generationTimeoutSec must be a positive number', 'IPC_BAD_INPUT'); + } + out.generationTimeoutSec = r['generationTimeoutSec']; + } + return out; +} + +export function registerPreferencesIpc(): void { + ipcMain.handle('preferences:get', async (): Promise => { + return readPersisted(); + }); + + ipcMain.handle('preferences:update', async (_e, raw: unknown): Promise => { + const patch = parsePreferences(raw); + const current = await readPersisted(); + const next: Preferences = { ...current, ...patch }; + await writePersisted(next); + return next; + }); +} diff --git a/apps/desktop/src/main/prompt-context.test.ts b/apps/desktop/src/main/prompt-context.test.ts deleted file mode 100644 index 2fb9d318..00000000 --- a/apps/desktop/src/main/prompt-context.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { preparePromptContext } from './prompt-context'; - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe('preparePromptContext', () => { - it('throws a CodesignError when an attachment cannot be read', async () => { - await expect( - preparePromptContext({ - attachments: [{ path: 'Z:/missing/brief.md', name: 'brief.md', size: 12 }], - }), - ).rejects.toMatchObject({ - name: 'CodesignError', - code: 'ATTACHMENT_READ_FAILED', - }); - }); - - it('throws a CodesignError when an attachment is too large', async () => { - await expect( - preparePromptContext({ - attachments: [{ path: 'C:/repo/huge.txt', name: 'huge.txt', size: 300_000 }], - }), - ).rejects.toMatchObject({ - name: 'CodesignError', - code: 'ATTACHMENT_TOO_LARGE', - }); - }); - - it('throws a CodesignError for oversized reference responses', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue( - new Response('too big', { - status: 200, - headers: { - 'content-type': 'text/html; charset=utf-8', - 'content-length': '300000', - }, - }), - ), - ); - - await expect( - preparePromptContext({ - referenceUrl: 'https://example.com/reference', - }), - ).rejects.toMatchObject({ - name: 'CodesignError', - code: 'REFERENCE_URL_TOO_LARGE', - }); - }); -}); diff --git a/apps/desktop/src/main/prompt-context.ts b/apps/desktop/src/main/prompt-context.ts deleted file mode 100644 index ad7f899c..00000000 --- a/apps/desktop/src/main/prompt-context.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { open } from 'node:fs/promises'; -import { extname } from 'node:path'; -import type { AttachmentContext, ReferenceUrlContext } from '@open-codesign/core'; -import { CodesignError, type LocalInputFile, type StoredDesignSystem } from '@open-codesign/shared'; - -const TEXT_EXTS = new Set([ - '.css', - '.csv', - '.html', - '.js', - '.json', - '.jsx', - '.less', - '.md', - '.mjs', - '.scss', - '.svg', - '.ts', - '.tsx', - '.txt', - '.xml', - '.yaml', - '.yml', -]); - -const MAX_ATTACHMENT_CHARS = 6_000; -const MAX_ATTACHMENT_BYTES = 256_000; -const MAX_URL_EXCERPT_CHARS = 1_200; -const MAX_URL_RESPONSE_BYTES = 256_000; -const REFERENCE_CONTENT_TYPES = ['text/html', 'application/xhtml+xml']; - -function cleanText(raw: string, maxChars: number): string { - return raw - .replace(/\r\n/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim() - .slice(0, maxChars); -} - -function stripHtml(raw: string): string { - return raw - .replace(//gi, ' ') - .replace(//gi, ' ') - .replace(/<[^>]+>/g, ' ') - .replace(/\s+/g, ' ') - .trim(); -} - -function isProbablyText(buffer: Buffer, extension: string): boolean { - if (TEXT_EXTS.has(extension)) return true; - const probe = buffer.subarray(0, 512); - return !probe.includes(0); -} - -async function readAttachment(file: LocalInputFile): Promise { - const extension = extname(file.name).toLowerCase(); - if (file.size > MAX_ATTACHMENT_BYTES) { - throw new CodesignError( - `Attachment "${file.name}" is too large (${file.size} bytes).`, - 'ATTACHMENT_TOO_LARGE', - ); - } - - let buffer: Buffer; - let handle: Awaited> | null = null; - try { - handle = await open(file.path, 'r'); - const length = Math.max(1, Math.min(file.size || MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_BYTES)); - const readBuffer = Buffer.alloc(length); - const { bytesRead } = await handle.read(readBuffer, 0, readBuffer.length, 0); - buffer = readBuffer.subarray(0, bytesRead); - } catch (error) { - throw new CodesignError(`Failed to read attachment "${file.path}"`, 'ATTACHMENT_READ_FAILED', { - cause: error, - }); - } finally { - await handle?.close(); - } - - if (!isProbablyText(buffer, extension)) { - return { - name: file.name, - path: file.path, - note: `Binary or unsupported format (${extension || 'unknown'}). Use the filename as a hint, not quoted content.`, - }; - } - - return { - name: file.name, - path: file.path, - excerpt: cleanText(buffer.toString('utf8'), MAX_ATTACHMENT_CHARS), - note: - buffer.length > MAX_ATTACHMENT_CHARS - ? 'Excerpt truncated to the most relevant leading content.' - : undefined, - }; -} - -async function readResponseText(response: Response, url: string): Promise { - const contentLength = Number(response.headers.get('content-length') ?? 0); - if (Number.isFinite(contentLength) && contentLength > MAX_URL_RESPONSE_BYTES) { - throw new CodesignError( - `Reference URL response is too large (${contentLength} bytes) for ${url}`, - 'REFERENCE_URL_TOO_LARGE', - ); - } - - if (!response.body) { - const text = await response.text(); - if (Buffer.byteLength(text, 'utf8') > MAX_URL_RESPONSE_BYTES) { - throw new CodesignError( - `Reference URL response is too large for ${url}`, - 'REFERENCE_URL_TOO_LARGE', - ); - } - return text; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let totalBytes = 0; - let text = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (!value) continue; - - totalBytes += value.byteLength; - if (totalBytes > MAX_URL_RESPONSE_BYTES) { - throw new CodesignError( - `Reference URL response is too large for ${url}`, - 'REFERENCE_URL_TOO_LARGE', - ); - } - - text += decoder.decode(value, { stream: true }); - } - text += decoder.decode(); - return text; - } finally { - reader.releaseLock(); - } -} - -async function inspectReferenceUrl(url: string): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 4_000); - try { - const response = await fetch(url, { - headers: { 'user-agent': 'open-codesign/0.0.0 (+local desktop app)' }, - signal: controller.signal, - }); - if (!response.ok) { - throw new CodesignError( - `Reference URL fetch failed (${response.status}) for ${url}`, - 'REFERENCE_URL_FETCH_FAILED', - ); - } - - const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; - if (!REFERENCE_CONTENT_TYPES.some((type) => contentType.includes(type))) { - throw new CodesignError( - `Unsupported reference URL content type "${contentType || 'unknown'}" for ${url}`, - 'REFERENCE_URL_UNSUPPORTED', - ); - } - - const html = await readResponseText(response, url); - const title = html.match(/]*>([\s\S]*?)<\/title>/i)?.[1]?.trim(); - const description = - html.match(/]+name=["']description["'][^>]+content=["']([^"']+)["']/i)?.[1] ?? - html.match(/]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i)?.[1]; - - return { - url, - ...(title ? { title } : {}), - ...(description ? { description } : {}), - excerpt: cleanText(stripHtml(html), MAX_URL_EXCERPT_CHARS), - }; - } catch (error) { - if (error instanceof CodesignError) throw error; - const code = - error instanceof Error && error.name === 'AbortError' - ? 'REFERENCE_URL_FETCH_TIMEOUT' - : 'REFERENCE_URL_FETCH_FAILED'; - const message = - code === 'REFERENCE_URL_FETCH_TIMEOUT' - ? `Reference URL request timed out for ${url}` - : `Failed to fetch reference URL ${url}`; - throw new CodesignError(message, code, { cause: error }); - } finally { - clearTimeout(timer); - } -} - -export interface PreparedPromptContext { - designSystem: StoredDesignSystem | null; - attachments: AttachmentContext[]; - referenceUrl: ReferenceUrlContext | null; -} - -export async function preparePromptContext(input: { - attachments?: LocalInputFile[] | undefined; - referenceUrl?: string | undefined; - designSystem?: StoredDesignSystem | null | undefined; -}): Promise { - const attachments = await Promise.all( - (input.attachments ?? []).map((file) => readAttachment(file)), - ); - const referenceUrl = - typeof input.referenceUrl === 'string' && input.referenceUrl.trim().length > 0 - ? await inspectReferenceUrl(input.referenceUrl.trim()) - : null; - - return { - designSystem: input.designSystem ?? null, - attachments, - referenceUrl, - }; -} diff --git a/apps/desktop/src/main/provider-settings.test.ts b/apps/desktop/src/main/provider-settings.test.ts new file mode 100644 index 00000000..08b45d5e --- /dev/null +++ b/apps/desktop/src/main/provider-settings.test.ts @@ -0,0 +1,94 @@ +import { CodesignError, type Config } from '@open-codesign/shared'; +import { describe, expect, it } from 'vitest'; +import { + assertProviderHasStoredSecret, + getAddProviderDefaults, + toProviderRows, +} from './provider-settings'; + +describe('getAddProviderDefaults', () => { + it('activates the newly added provider when the cached active provider has no saved secret', () => { + const cfg: Config = { + version: 1, + provider: 'openai', + modelPrimary: 'gpt-4o', + modelFast: 'gpt-4o-mini', + secrets: {}, + baseUrls: {}, + }; + + const defaults = getAddProviderDefaults(cfg, { + provider: 'anthropic', + modelPrimary: 'claude-sonnet-4-6', + modelFast: 'claude-haiku-3', + }); + + expect(defaults).toEqual({ + activeProvider: 'anthropic', + modelPrimary: 'claude-sonnet-4-6', + modelFast: 'claude-haiku-3', + }); + }); +}); + +describe('toProviderRows', () => { + it('returns a row with error:decryption_failed and empty maskedKey when decrypt throws', () => { + const cfg: Config = { + version: 1, + provider: 'openai', + modelPrimary: 'gpt-4o', + modelFast: 'gpt-4o-mini', + secrets: { + openai: { ciphertext: 'bad-ciphertext' }, + }, + baseUrls: {}, + }; + + // Should NOT throw — decryption failure is now soft-handled. + const rows = toProviderRows(cfg, () => { + throw new Error('safeStorage unavailable'); + }); + + expect(rows).toHaveLength(1); + expect(rows[0]?.error).toBe('decryption_failed'); + expect(rows[0]?.maskedKey).toBe(''); + expect(rows[0]?.provider).toBe('openai'); + }); + + it('returns a normal masked row when decrypt succeeds', () => { + const cfg: Config = { + version: 1, + provider: 'anthropic', + modelPrimary: 'claude-sonnet-4-6', + modelFast: 'claude-haiku-3', + secrets: { + anthropic: { ciphertext: 'enc' }, + }, + baseUrls: {}, + }; + + const rows = toProviderRows(cfg, () => 'sk-ant-api03-abcdefghijklmnop'); + + expect(rows).toHaveLength(1); + expect(rows[0]?.error).toBeUndefined(); + expect(rows[0]?.maskedKey).toMatch(/sk-.*\*{3}/); + expect(rows[0]?.isActive).toBe(true); + }); +}); + +describe('assertProviderHasStoredSecret', () => { + it('throws when activating a provider without a stored API key', () => { + const cfg: Config = { + version: 1, + provider: 'openai', + modelPrimary: 'gpt-4o', + modelFast: 'gpt-4o-mini', + secrets: { + openai: { ciphertext: 'ciphertext' }, + }, + baseUrls: {}, + }; + + expect(() => assertProviderHasStoredSecret(cfg, 'anthropic')).toThrow(CodesignError); + }); +}); diff --git a/apps/desktop/src/main/provider-settings.ts b/apps/desktop/src/main/provider-settings.ts new file mode 100644 index 00000000..e34cfb7a --- /dev/null +++ b/apps/desktop/src/main/provider-settings.ts @@ -0,0 +1,95 @@ +import { + CodesignError, + type Config, + type SupportedOnboardingProvider, + isSupportedOnboardingProvider, +} from '@open-codesign/shared'; + +export interface ProviderRow { + provider: SupportedOnboardingProvider; + maskedKey: string; + baseUrl: string | null; + isActive: boolean; + error?: 'decryption_failed' | string; +} + +export function maskKey(plain: string): string { + if (plain.length <= 8) return '***'; + const prefix = plain.startsWith('sk-') ? 'sk-' : plain.slice(0, 4); + const suffix = plain.slice(-4); + return `${prefix}***${suffix}`; +} + +export function getAddProviderDefaults( + cfg: Config | null, + input: { + provider: SupportedOnboardingProvider; + modelPrimary: string; + modelFast: string; + }, +): { + activeProvider: SupportedOnboardingProvider; + modelPrimary: string; + modelFast: string; +} { + if ( + cfg === null || + !isSupportedOnboardingProvider(cfg.provider) || + cfg.secrets[cfg.provider] === undefined + ) { + return { + activeProvider: input.provider, + modelPrimary: input.modelPrimary, + modelFast: input.modelFast, + }; + } + const activeProvider: SupportedOnboardingProvider = cfg.provider; + + return { + activeProvider, + modelPrimary: cfg.modelPrimary, + modelFast: cfg.modelFast, + }; +} + +export function assertProviderHasStoredSecret( + cfg: Config, + provider: SupportedOnboardingProvider, +): void { + if (cfg.secrets[provider] !== undefined) return; + throw new CodesignError(`No API key stored for provider "${provider}".`, 'PROVIDER_KEY_MISSING'); +} + +export function toProviderRows( + cfg: Config | null, + decrypt: (ciphertext: string) => string, +): ProviderRow[] { + if (cfg === null) return []; + + const rows: ProviderRow[] = []; + for (const [provider, ref] of Object.entries(cfg.secrets)) { + if (!isSupportedOnboardingProvider(provider) || ref === undefined) continue; + const supportedProvider: SupportedOnboardingProvider = provider; + + let maskedKey: string; + let rowError: ProviderRow['error']; + try { + const plain = decrypt(ref.ciphertext); + maskedKey = maskKey(plain); + } catch { + // Surface decryption failure to the UI instead of silently masking or hard-crashing. + maskedKey = ''; + rowError = 'decryption_failed'; + } + + rows.push({ + provider: supportedProvider, + maskedKey, + baseUrl: cfg.baseUrls?.[supportedProvider]?.baseUrl ?? null, + isActive: cfg.provider === supportedProvider, + ...(rowError !== undefined ? { error: rowError } : {}), + }); + } + + return rows; +} diff --git a/apps/desktop/src/main/storage-settings.test.ts b/apps/desktop/src/main/storage-settings.test.ts new file mode 100644 index 00000000..fc92c429 --- /dev/null +++ b/apps/desktop/src/main/storage-settings.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { buildAppPaths } from './storage-settings'; + +describe('buildAppPaths', () => { + it('returns file paths and their containing folders for config and logs', () => { + const paths = buildAppPaths( + '/tmp/open-codesign/config.toml', + '/tmp/open-codesign/logs/main.log', + '/tmp/open-codesign', + ); + + expect(paths).toEqual({ + config: '/tmp/open-codesign/config.toml', + configFolder: '/tmp/open-codesign', + logs: '/tmp/open-codesign/logs/main.log', + logsFolder: '/tmp/open-codesign/logs', + data: '/tmp/open-codesign', + }); + }); +}); diff --git a/apps/desktop/src/main/storage-settings.ts b/apps/desktop/src/main/storage-settings.ts new file mode 100644 index 00000000..d173260d --- /dev/null +++ b/apps/desktop/src/main/storage-settings.ts @@ -0,0 +1,19 @@ +import { dirname } from 'node:path'; + +export interface AppPaths { + config: string; + configFolder: string; + logs: string; + logsFolder: string; + data: string; +} + +export function buildAppPaths(configFile: string, logFile: string, dataDir: string): AppPaths { + return { + config: configFile, + configFolder: dirname(configFile), + logs: logFile, + logsFolder: dirname(logFile), + data: dataDir, + }; +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index b226cfc6..af877ac8 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -5,7 +5,6 @@ import type { LocalInputFile, ModelRef, OnboardingState, - SelectedElement, SupportedOnboardingProvider, } from '@open-codesign/shared'; import { contextBridge, ipcRenderer } from 'electron'; @@ -27,6 +26,29 @@ export interface ExportInvokeResponse { bytes?: number; } +export interface ProviderRow { + provider: SupportedOnboardingProvider; + maskedKey: string; + baseUrl: string | null; + isActive: boolean; + error?: 'decryption_failed' | string; +} + +export interface AppPaths { + config: string; + configFolder: string; + logs: string; + logsFolder: string; + data: string; +} + +export type UpdateChannel = 'stable' | 'beta'; + +export interface Preferences { + updateChannel: UpdateChannel; + generationTimeoutSec: number; +} + const api = { detectProvider: (key: string) => ipcRenderer.invoke('codesign:detect-provider', key) as Promise, @@ -96,6 +118,40 @@ const api = { }) => ipcRenderer.invoke('onboarding:save-key', input) as Promise, skip: () => ipcRenderer.invoke('onboarding:skip') as Promise, }, + settings: { + listProviders: () => ipcRenderer.invoke('settings:list-providers') as Promise, + addProvider: (input: { + provider: SupportedOnboardingProvider; + apiKey: string; + modelPrimary: string; + modelFast: string; + baseUrl?: string; + }) => ipcRenderer.invoke('settings:add-provider', input) as Promise, + deleteProvider: (provider: SupportedOnboardingProvider) => + ipcRenderer.invoke('settings:delete-provider', provider) as Promise, + setActiveProvider: (input: { + provider: SupportedOnboardingProvider; + modelPrimary: string; + modelFast: string; + }) => ipcRenderer.invoke('settings:set-active-provider', input) as Promise, + getPaths: () => ipcRenderer.invoke('settings:get-paths') as Promise, + openFolder: (path: string) => ipcRenderer.invoke('settings:open-folder', path) as Promise, + resetOnboarding: () => ipcRenderer.invoke('settings:reset-onboarding') as Promise, + toggleDevtools: () => ipcRenderer.invoke('settings:toggle-devtools') as Promise, + validateKey: (input: { + provider: SupportedOnboardingProvider; + apiKey: string; + baseUrl?: string; + }) => + ipcRenderer.invoke('onboarding:validate-key', input) as Promise< + ValidateKeyResult | ValidateKeyError + >, + }, + preferences: { + get: () => ipcRenderer.invoke('preferences:get') as Promise, + update: (patch: Partial) => + ipcRenderer.invoke('preferences:update', patch) 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 d7458eed..0bf1614a 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -1,4 +1,3 @@ -import { useT } from '@open-codesign/i18n'; import { useEffect, useMemo, useState } from 'react'; import { CommandPalette } from './components/CommandPalette'; import { PreviewPane } from './components/PreviewPane'; @@ -11,7 +10,6 @@ import { Onboarding } from './onboarding'; import { useCodesignStore } from './store'; export function App() { - const t = useT(); const config = useCodesignStore((s) => s.config); const configLoaded = useCodesignStore((s) => s.configLoaded); const loadConfig = useCodesignStore((s) => s.loadConfig); @@ -33,7 +31,7 @@ export function App() { function submit(): void { const trimmed = prompt.trim(); if (!trimmed || isGenerating) return; - void sendPrompt({ prompt: trimmed }); + void sendPrompt(trimmed); setPrompt(''); } @@ -47,7 +45,7 @@ export function App() { if (!ready) return; const trimmed = prompt.trim(); if (!trimmed || isGenerating) return; - void sendPrompt({ prompt: trimmed }); + void sendPrompt(trimmed); setPrompt(''); }, }, @@ -92,7 +90,7 @@ export function App() { if (!configLoaded) { return (
- {t('common.loading')} + Loading…
); } diff --git a/apps/desktop/src/renderer/src/components/CanvasErrorBar.tsx b/apps/desktop/src/renderer/src/components/CanvasErrorBar.tsx index 69a6d599..5a7342e6 100644 --- a/apps/desktop/src/renderer/src/components/CanvasErrorBar.tsx +++ b/apps/desktop/src/renderer/src/components/CanvasErrorBar.tsx @@ -1,9 +1,16 @@ -import { useT } from '@open-codesign/i18n'; +/** + * CanvasErrorBar — slim red strip shown above the preview iframe when the + * sandbox runtime postMessages an IFRAME_ERROR. + * + * Loud surface (PRINCIPLES §10): every JS exception thrown inside the + * generated HTML lands here with file + line. Users can dismiss the bar + * (it clears the store slice) but errors are never auto-hidden. + */ + import { X } from 'lucide-react'; import { useCodesignStore } from '../store'; export function CanvasErrorBar() { - const t = useT(); const errors = useCodesignStore((s) => s.iframeErrors); const clear = useCodesignStore((s) => s.clearIframeErrors); if (errors.length === 0) return null; @@ -17,8 +24,7 @@ export function CanvasErrorBar() {
- {t('preview.runtimeError')} - {errors.length > 1 ? ` (${errors.length})` : ''} + Preview runtime error{errors.length > 1 ? ` (${errors.length})` : ''}
{latest} @@ -27,7 +33,7 @@ export function CanvasErrorBar() { diff --git a/apps/desktop/src/renderer/src/components/InlineCommentComposer.tsx b/apps/desktop/src/renderer/src/components/InlineCommentComposer.tsx deleted file mode 100644 index 9ef56dea..00000000 --- a/apps/desktop/src/renderer/src/components/InlineCommentComposer.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useT } from '@open-codesign/i18n'; -import type { SelectedElement } from '@open-codesign/shared'; -import { MessageSquareText, X } from 'lucide-react'; -import { useState } from 'react'; -import { useCodesignStore } from '../store'; - -export function InlineCommentComposer() { - const selectedElement = useCodesignStore((s) => s.selectedElement); - if (!selectedElement) return null; - return ( - - ); -} - -interface InlineCommentComposerCardProps { - selectedElement: SelectedElement; -} - -function InlineCommentComposerCard({ selectedElement }: InlineCommentComposerCardProps) { - const t = useT(); - const clearCanvasElement = useCodesignStore((s) => s.clearCanvasElement); - const applyInlineComment = useCodesignStore((s) => s.applyInlineComment); - const isGenerating = useCodesignStore((s) => s.isGenerating); - const [draft, setDraft] = useState(''); - - return ( -
-
-
-
- - {t('inlineComment.title')} {selectedElement.tag} -
-

- {selectedElement.selector} -

-
- -
- -
-

- {t('inlineComment.description')} -

-