diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 5f0c0d41..03ce2dd2 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -28,11 +28,9 @@ import { registerOnboardingIpc, setDesignSystem, } from './onboarding-ipc'; +import { registerPreferencesIpc } from './preferences-ipc'; import { preparePromptContext } from './prompt-context'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - let mainWindow: ElectronBrowserWindow | null = null; function createWindow(): void { @@ -353,6 +351,7 @@ void app.whenReady().then(async () => { registerIpcHandlers(); registerLocaleIpc(); registerOnboardingIpc(); + registerPreferencesIpc(); registerExporterIpc(() => mainWindow); setupAutoUpdater(); createWindow(); diff --git a/apps/desktop/src/main/onboarding-ipc.test.ts b/apps/desktop/src/main/onboarding-ipc.test.ts new file mode 100644 index 00000000..0eb96c51 --- /dev/null +++ b/apps/desktop/src/main/onboarding-ipc.test.ts @@ -0,0 +1,111 @@ +/** + * Tests for settings IPC channel versioning. + * + * These tests verify that registerOnboardingIpc registers both the versioned + * v1 channels and the legacy shim channels, ensuring backward compat for + * callers that haven't migrated yet. + */ + +import { describe, expect, it, vi } from 'vitest'; + +// Collect registered channel names via a mock ipcMain. +const registeredChannels: string[] = []; + +vi.mock('./electron-runtime', () => ({ + ipcMain: { + handle: (channel: string) => { + registeredChannels.push(channel); + }, + }, + shell: { openPath: vi.fn() }, +})); + +// Stub Electron modules that electron-runtime would otherwise pull in. +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false, getVersion: vi.fn(() => '0.0.0') }, + ipcMain: { handle: vi.fn() }, + safeStorage: { isEncryptionAvailable: vi.fn(() => false) }, + shell: { openPath: vi.fn() }, +})); + +vi.mock('electron-log/main', () => ({ + default: { + scope: () => ({ + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }), + transports: { + file: { resolvePathFn: null, maxSize: 0, format: '' }, + console: { level: 'info', format: '' }, + }, + errorHandler: { startCatching: vi.fn() }, + eventLogger: { startLogging: vi.fn() }, + info: vi.fn(), + }, +})); + +vi.mock('./config', () => ({ + configPath: () => '/tmp/config.toml', + configDir: () => '/tmp', + readConfig: vi.fn(async () => null), + writeConfig: vi.fn(async () => {}), +})); + +vi.mock('./keychain', () => ({ + encryptSecret: vi.fn((s: string) => `enc:${s}`), + decryptSecret: vi.fn((s: string) => s.replace('enc:', '')), +})); + +vi.mock('./storage-settings', () => ({ + buildAppPaths: vi.fn(() => ({})), +})); + +vi.mock('@open-codesign/providers', () => ({ + pingProvider: vi.fn(async () => ({ ok: true, modelCount: 1 })), +})); + +describe('registerOnboardingIpc — channel versioning', () => { + it('registers settings:v1:list-providers alongside the legacy settings:list-providers shim', async () => { + // Import after mocks are in place. + const { registerOnboardingIpc } = await import('./onboarding-ipc'); + registerOnboardingIpc(); + + expect(registeredChannels).toContain('settings:v1:list-providers'); + expect(registeredChannels).toContain('settings:list-providers'); + }); + + it('registers all eight settings v1 channels', async () => { + const v1Channels = [ + 'settings:v1:list-providers', + 'settings:v1:add-provider', + 'settings:v1:delete-provider', + 'settings:v1:set-active-provider', + 'settings:v1:get-paths', + 'settings:v1:open-folder', + 'settings:v1:reset-onboarding', + 'settings:v1:toggle-devtools', + ]; + + for (const ch of v1Channels) { + expect(registeredChannels).toContain(ch); + } + }); + + it('preserves all eight legacy settings shim channels for backward compat', async () => { + const legacyChannels = [ + 'settings:list-providers', + 'settings:add-provider', + 'settings:delete-provider', + 'settings:set-active-provider', + 'settings:get-paths', + 'settings:open-folder', + 'settings:reset-onboarding', + 'settings:toggle-devtools', + ]; + + for (const ch of legacyChannels) { + expect(registeredChannels).toContain(ch); + } + }); +}); diff --git a/apps/desktop/src/main/onboarding-ipc.ts b/apps/desktop/src/main/onboarding-ipc.ts index d633bf6f..101fe7ed 100644 --- a/apps/desktop/src/main/onboarding-ipc.ts +++ b/apps/desktop/src/main/onboarding-ipc.ts @@ -8,9 +8,20 @@ import { 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, getLogger } from './logger'; +import { + type ProviderRow, + assertProviderHasStoredSecret, + computeDeleteProviderResult, + getAddProviderDefaults, + toProviderRows, +} from './provider-settings'; +import { buildAppPaths } from './storage-settings'; + +const logger = getLogger('settings-ipc'); interface SaveKeyInput { provider: SupportedOnboardingProvider; @@ -26,6 +37,8 @@ interface ValidateKeyInput { baseUrl?: string; } +export type { ProviderRow } from './provider-settings'; + let cachedConfig: Config | null = null; let configLoaded = false; @@ -63,7 +76,7 @@ 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, @@ -194,6 +207,138 @@ function parseValidateKey(raw: unknown): ValidateKeyInput { return out; } +// ── Settings handler implementations (shared by v1 and legacy channels) ─────── + +function runListProviders(): ProviderRow[] { + return toProviderRows(getCachedConfig(), decryptSecret); +} + +async function runAddProvider(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); +} + +async function runDeleteProvider(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]; + + const { nextActive, modelPrimary, modelFast } = computeDeleteProviderResult(cfg, raw); + + if (nextActive === null) { + // No providers left — write a tombstone config so onboarding triggers again. + const emptyNext: Config = { + version: 1, + provider: cfg.provider, + modelPrimary: '', + modelFast: '', + secrets: {}, + baseUrls: {}, + }; + await writeConfig(emptyNext); + cachedConfig = emptyNext; + return toProviderRows(cachedConfig, decryptSecret); + } + + const next: Config = { + version: 1, + provider: nextActive, + modelPrimary, + modelFast, + secrets: nextSecrets, + baseUrls: nextBaseUrls, + }; + await writeConfig(next); + cachedConfig = next; + return toProviderRows(cachedConfig, decryptSecret); +} + +async function runSetActiveProvider(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); +} + +function runGetPaths() { + return buildAppPaths(configPath(), getLogPath(), configDir()); +} + +async function runOpenFolder(raw: unknown): Promise { + 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'); + } +} + +async function runResetOnboarding(): 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; +} + export function registerOnboardingIpc(): void { ipcMain.handle('onboarding:get-state', (): OnboardingState => toState(getCachedConfig())); @@ -221,7 +366,6 @@ export function registerOnboardingIpc(): void { [input.provider]: { ciphertext }, }, baseUrls: nextBaseUrls, - ...(cachedConfig?.designSystem ? { designSystem: cachedConfig.designSystem } : {}), }; await writeConfig(next); cachedConfig = next; @@ -232,4 +376,81 @@ export function registerOnboardingIpc(): void { ipcMain.handle('onboarding:skip', async (): Promise => { return toState(cachedConfig); }); + + // ── Settings v1 channels ──────────────────────────────────────────────────── + + ipcMain.handle('settings:v1:list-providers', (): ProviderRow[] => runListProviders()); + + ipcMain.handle( + 'settings:v1:add-provider', + async (_e, raw: unknown): Promise => runAddProvider(raw), + ); + + ipcMain.handle( + 'settings:v1:delete-provider', + async (_e, raw: unknown): Promise => runDeleteProvider(raw), + ); + + ipcMain.handle( + 'settings:v1:set-active-provider', + async (_e, raw: unknown): Promise => runSetActiveProvider(raw), + ); + + ipcMain.handle('settings:v1:get-paths', () => runGetPaths()); + + ipcMain.handle( + 'settings:v1:open-folder', + async (_e, raw: unknown): Promise => runOpenFolder(raw), + ); + + ipcMain.handle('settings:v1:reset-onboarding', async (): Promise => runResetOnboarding()); + + ipcMain.handle('settings:v1:toggle-devtools', (_e) => { + _e.sender.toggleDevTools(); + }); + + // ── Settings legacy shims (schedule removal next minor) ──────────────────── + + ipcMain.handle('settings:list-providers', (): ProviderRow[] => { + logger.warn('legacy settings:list-providers channel used, schedule removal next minor'); + return runListProviders(); + }); + + ipcMain.handle('settings:add-provider', async (_e, raw: unknown): Promise => { + logger.warn('legacy settings:add-provider channel used, schedule removal next minor'); + return runAddProvider(raw); + }); + + ipcMain.handle('settings:delete-provider', async (_e, raw: unknown): Promise => { + logger.warn('legacy settings:delete-provider channel used, schedule removal next minor'); + return runDeleteProvider(raw); + }); + + ipcMain.handle( + 'settings:set-active-provider', + async (_e, raw: unknown): Promise => { + logger.warn('legacy settings:set-active-provider channel used, schedule removal next minor'); + return runSetActiveProvider(raw); + }, + ); + + ipcMain.handle('settings:get-paths', () => { + logger.warn('legacy settings:get-paths channel used, schedule removal next minor'); + return runGetPaths(); + }); + + ipcMain.handle('settings:open-folder', async (_e, raw: unknown) => { + logger.warn('legacy settings:open-folder channel used, schedule removal next minor'); + return runOpenFolder(raw); + }); + + ipcMain.handle('settings:reset-onboarding', async (): Promise => { + logger.warn('legacy settings:reset-onboarding channel used, schedule removal next minor'); + return runResetOnboarding(); + }); + + ipcMain.handle('settings:toggle-devtools', (_e) => { + logger.warn('legacy settings:toggle-devtools channel used, schedule removal next minor'); + _e.sender.toggleDevTools(); + }); } diff --git a/apps/desktop/src/main/preferences-ipc.test.ts b/apps/desktop/src/main/preferences-ipc.test.ts new file mode 100644 index 00000000..cc8fff00 --- /dev/null +++ b/apps/desktop/src/main/preferences-ipc.test.ts @@ -0,0 +1,55 @@ +import { CodesignError } from '@open-codesign/shared'; +import { describe, expect, it, vi } from 'vitest'; + +// Mock electron and logger before importing the module under test. +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn() }, +})); + +vi.mock('electron-log/main', () => ({ + default: { + scope: () => ({ + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }), + transports: { + file: { resolvePathFn: null, maxSize: 0, format: '' }, + console: { level: 'info', format: '' }, + }, + errorHandler: { startCatching: vi.fn() }, + eventLogger: { startLogging: vi.fn() }, + info: vi.fn(), + }, +})); + +const readFileMock = vi.fn(); + +vi.mock('node:fs/promises', () => ({ + readFile: (...args: unknown[]) => readFileMock(...args), + writeFile: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), +})); + +import { readPersisted } from './preferences-ipc'; + +describe('readPersisted()', () => { + it('returns defaults when the file does not exist (ENOENT)', async () => { + const notFound = Object.assign(new Error('no such file'), { code: 'ENOENT' }); + readFileMock.mockRejectedValueOnce(notFound); + + const result = await readPersisted(); + expect(result).toEqual({ updateChannel: 'stable', generationTimeoutSec: 120 }); + }); + + it('throws CodesignError with PREFERENCES_READ_FAILED on a non-ENOENT error (e.g. EACCES)', async () => { + const permissionDenied = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + readFileMock.mockRejectedValueOnce(permissionDenied); + + await expect(readPersisted()).rejects.toBeInstanceOf(CodesignError); + + readFileMock.mockRejectedValueOnce(permissionDenied); + const err = await readPersisted().catch((e: unknown) => e); + expect((err as CodesignError).code).toBe('PREFERENCES_READ_FAILED'); + }); +}); diff --git a/apps/desktop/src/main/preferences-ipc.ts b/apps/desktop/src/main/preferences-ipc.ts new file mode 100644 index 00000000..b11df6f4 --- /dev/null +++ b/apps/desktop/src/main/preferences-ipc.ts @@ -0,0 +1,120 @@ +/** + * 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'; +import { getLogger } from './logger'; + +const logger = getLogger('preferences-ipc'); + +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, +}; + +export 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 }; + throw new CodesignError( + `Failed to read preferences at ${PREFS_FILE}: ${err instanceof Error ? err.message : String(err)}`, + 'PREFERENCES_READ_FAILED', + ); + } +} + +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 { + // ── Preferences v1 channels ───────────────────────────────────────────────── + + ipcMain.handle('preferences:v1:get', async (): Promise => { + return readPersisted(); + }); + + ipcMain.handle('preferences:v1:update', async (_e, raw: unknown): Promise => { + const patch = parsePreferences(raw); + const current = await readPersisted(); + const next: Preferences = { ...current, ...patch }; + await writePersisted(next); + return next; + }); + + // ── Preferences legacy shims (schedule removal next minor) ────────────────── + + ipcMain.handle('preferences:get', async (): Promise => { + logger.warn('legacy preferences:get channel used, schedule removal next minor'); + return readPersisted(); + }); + + ipcMain.handle('preferences:update', async (_e, raw: unknown): Promise => { + logger.warn('legacy preferences:update channel used, schedule removal next minor'); + 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/provider-settings.test.ts b/apps/desktop/src/main/provider-settings.test.ts new file mode 100644 index 00000000..17596438 --- /dev/null +++ b/apps/desktop/src/main/provider-settings.test.ts @@ -0,0 +1,156 @@ +import { CodesignError, type Config } from '@open-codesign/shared'; +import { describe, expect, it } from 'vitest'; +import { + assertProviderHasStoredSecret, + computeDeleteProviderResult, + 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); + }); +}); + +describe('computeDeleteProviderResult', () => { + it('switches to the next provider default models when the active provider is deleted', () => { + const cfg: Config = { + version: 1, + provider: 'anthropic', + modelPrimary: 'claude-sonnet-4-6', + modelFast: 'claude-haiku-3', + secrets: { + anthropic: { ciphertext: 'enc-ant' }, + openai: { ciphertext: 'enc-oai' }, + }, + baseUrls: {}, + }; + + const result = computeDeleteProviderResult(cfg, 'anthropic'); + + expect(result.nextActive).toBe('openai'); + expect(result.modelPrimary).toBe('gpt-4o'); + expect(result.modelFast).toBe('gpt-4o-mini'); + }); + + it('keeps existing models when a non-active provider is deleted', () => { + const cfg: Config = { + version: 1, + provider: 'anthropic', + modelPrimary: 'claude-sonnet-4-6', + modelFast: 'claude-haiku-3', + secrets: { + anthropic: { ciphertext: 'enc-ant' }, + openai: { ciphertext: 'enc-oai' }, + }, + baseUrls: {}, + }; + + const result = computeDeleteProviderResult(cfg, 'openai'); + + expect(result.nextActive).toBe('anthropic'); + expect(result.modelPrimary).toBe('claude-sonnet-4-6'); + expect(result.modelFast).toBe('claude-haiku-3'); + }); + + it('returns nextActive null and empty models when the last provider is deleted', () => { + const cfg: Config = { + version: 1, + provider: 'openai', + modelPrimary: 'gpt-4o', + modelFast: 'gpt-4o-mini', + secrets: { + openai: { ciphertext: 'enc-oai' }, + }, + baseUrls: {}, + }; + + const result = computeDeleteProviderResult(cfg, 'openai'); + + expect(result.nextActive).toBeNull(); + expect(result.modelPrimary).toBe(''); + expect(result.modelFast).toBe(''); + }); +}); diff --git a/apps/desktop/src/main/provider-settings.ts b/apps/desktop/src/main/provider-settings.ts new file mode 100644 index 00000000..35a5a114 --- /dev/null +++ b/apps/desktop/src/main/provider-settings.ts @@ -0,0 +1,138 @@ +import { + CodesignError, + type Config, + PROVIDER_SHORTLIST, + 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; +} + +export interface DeleteProviderResult { + /** null means tombstone: all providers removed, onboarding should re-run. */ + nextActive: SupportedOnboardingProvider | null; + modelPrimary: string; + modelFast: string; +} + +/** + * Pure helper: given the current config and the provider to remove, computes + * what the next active provider and model values should be. + * Extracted for unit-testability without Electron IPC. + */ +export function computeDeleteProviderResult( + cfg: Config, + toDelete: SupportedOnboardingProvider, +): DeleteProviderResult { + const remaining = Object.keys(cfg.secrets) + .filter((p) => p !== toDelete) + .filter(isSupportedOnboardingProvider); + + if (remaining.length === 0) { + return { nextActive: null, modelPrimary: '', modelFast: '' }; + } + + const keepCurrent = cfg.provider !== toDelete && isSupportedOnboardingProvider(cfg.provider); + const nextActive: SupportedOnboardingProvider = keepCurrent + ? (cfg.provider as SupportedOnboardingProvider) + : (remaining[0] as SupportedOnboardingProvider); + + // Only reset models when the active provider is the one being deleted. + if (cfg.provider === toDelete) { + const defaults = PROVIDER_SHORTLIST[nextActive]; + return { + nextActive, + modelPrimary: defaults.defaultPrimary, + modelFast: defaults.defaultFast, + }; + } + + return { nextActive, modelPrimary: cfg.modelPrimary, modelFast: cfg.modelFast }; +} 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..89d7558e 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -27,6 +27,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 +119,41 @@ const api = { }) => ipcRenderer.invoke('onboarding:save-key', input) as Promise, skip: () => ipcRenderer.invoke('onboarding:skip') as Promise, }, + settings: { + listProviders: () => ipcRenderer.invoke('settings:v1:list-providers') as Promise, + addProvider: (input: { + provider: SupportedOnboardingProvider; + apiKey: string; + modelPrimary: string; + modelFast: string; + baseUrl?: string; + }) => ipcRenderer.invoke('settings:v1:add-provider', input) as Promise, + deleteProvider: (provider: SupportedOnboardingProvider) => + ipcRenderer.invoke('settings:v1:delete-provider', provider) as Promise, + setActiveProvider: (input: { + provider: SupportedOnboardingProvider; + modelPrimary: string; + modelFast: string; + }) => ipcRenderer.invoke('settings:v1:set-active-provider', input) as Promise, + getPaths: () => ipcRenderer.invoke('settings:v1:get-paths') as Promise, + openFolder: (path: string) => + ipcRenderer.invoke('settings:v1:open-folder', path) as Promise, + resetOnboarding: () => ipcRenderer.invoke('settings:v1:reset-onboarding') as Promise, + toggleDevtools: () => ipcRenderer.invoke('settings:v1: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:v1:get') as Promise, + update: (patch: Partial) => + ipcRenderer.invoke('preferences:v1:update', patch) as Promise, + }, }; contextBridge.exposeInMainWorld('codesign', api); diff --git a/apps/desktop/src/renderer/src/components/Settings.test.ts b/apps/desktop/src/renderer/src/components/Settings.test.ts new file mode 100644 index 00000000..1308253e --- /dev/null +++ b/apps/desktop/src/renderer/src/components/Settings.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { applyValidateResult, canSaveProvider } from './Settings'; + +describe('canSaveProvider', () => { + it('requires a validated API key before enabling save', () => { + expect( + canSaveProvider({ + apiKey: 'sk-test', + validated: false, + validating: false, + }), + ).toBe(false); + }); + + it('stays disabled while validation is still in progress', () => { + expect( + canSaveProvider({ + apiKey: 'sk-test', + validated: true, + validating: true, + }), + ).toBe(false); + }); + + it('allows saving only after validation succeeds', () => { + expect( + canSaveProvider({ + apiKey: 'sk-test', + validated: true, + validating: false, + }), + ).toBe(true); + }); +}); + +describe('applyValidateResult', () => { + const baseForm = { + provider: 'anthropic' as const, + apiKey: 'sk-ant-original', + baseUrl: '', + modelPrimary: 'claude-sonnet-4-6', + modelFast: 'claude-haiku-3', + validating: true, + error: null, + validated: false, + }; + + const matchingSnapshot = { + provider: 'anthropic' as const, + apiKey: 'sk-ant-original', + baseUrl: '', + }; + + it('marks form validated when the snapshot still matches the current form', () => { + const next = applyValidateResult(baseForm, matchingSnapshot, true, undefined); + expect(next.validated).toBe(true); + expect(next.validating).toBe(false); + }); + + it('sets error when validation fails and the snapshot matches', () => { + const next = applyValidateResult(baseForm, matchingSnapshot, false, 'Invalid API key'); + expect(next.error).toBe('Invalid API key'); + expect(next.validated).toBe(false); + expect(next.validating).toBe(false); + }); + + it('discards the result when the API key changed while awaiting', () => { + const changedKeyForm = { ...baseForm, apiKey: 'sk-ant-changed' }; + const next = applyValidateResult(changedKeyForm, matchingSnapshot, true, undefined); + // Should return the unchanged form — validated must not flip to true. + expect(next).toBe(changedKeyForm); + expect(next.validated).toBe(false); + }); + + it('discards the result when the provider changed while awaiting', () => { + const changedProviderForm = { ...baseForm, provider: 'openai' as const }; + const next = applyValidateResult(changedProviderForm, matchingSnapshot, true, undefined); + expect(next).toBe(changedProviderForm); + expect(next.validated).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/Settings.tsx b/apps/desktop/src/renderer/src/components/Settings.tsx index 96116b48..b6ba845f 100644 --- a/apps/desktop/src/renderer/src/components/Settings.tsx +++ b/apps/desktop/src/renderer/src/components/Settings.tsx @@ -1,6 +1,31 @@ +import type { + OnboardingState, + PROVIDER_SHORTLIST, + SupportedOnboardingProvider, +} from '@open-codesign/shared'; +import { + PROVIDER_SHORTLIST as SHORTLIST, + isSupportedOnboardingProvider, +} from '@open-codesign/shared'; import { Button } from '@open-codesign/ui'; -import { Cpu, FolderOpen, Palette, Settings as SettingsIcon, Sliders, X } from 'lucide-react'; -import { useState } from 'react'; +import { + AlertTriangle, + CheckCircle, + ChevronDown, + Cpu, + FolderOpen, + Globe, + Loader2, + Palette, + Plus, + RotateCcw, + Sliders, + Trash2, + X, + Zap, +} from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import type { AppPaths, Preferences, ProviderRow } from '../../../preload/index'; import { useCodesignStore } from '../store'; type Tab = 'models' | 'appearance' | 'storage' | 'advanced'; @@ -12,100 +37,1067 @@ const TABS: ReadonlyArray<{ id: Tab; label: string; icon: typeof Cpu }> = [ { id: 'advanced', label: 'Advanced', icon: Sliders }, ]; -const CONFIG_PATH = '~/.config/open-codesign/config.toml'; +// ─── Tiny primitives ───────────────────────────────────────────────────────── + +function Label({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function Row({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+
+ + {hint && ( +

+ {hint} +

+ )} +
+
{children}
+
+ ); +} + +function SegmentedControl({ + options, + value, + onChange, + disabled, +}: { + options: { value: T; label: string }[]; + value: T; + onChange: (v: T) => void; + disabled?: boolean; +}) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +function NativeSelect({ + value, + onChange, + options, + disabled, +}: { + value: string; + onChange: (v: string) => void; + options: { value: string; label: string }[]; + disabled?: boolean; +}) { + return ( +
+ + +
+ ); +} + +function TextInput({ + value, + onChange, + placeholder, + type, + className, + disabled, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + type?: string; + className?: string; + disabled?: boolean; +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + disabled={disabled} + className={`h-8 px-3 rounded-[var(--radius-md)] bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--text-sm)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] disabled:opacity-50 disabled:cursor-not-allowed ${className ?? ''}`} + /> + ); +} + +// ─── Models tab ────────────────────────────────────────────────────────────── + +interface AddProviderFormState { + provider: SupportedOnboardingProvider; + apiKey: string; + baseUrl: string; + modelPrimary: string; + modelFast: string; + validating: boolean; + error: string | null; + validated: boolean; +} + +function makeDefaultForm(provider: SupportedOnboardingProvider): AddProviderFormState { + const sl = SHORTLIST[provider]; + return { + provider, + apiKey: '', + baseUrl: '', + modelPrimary: sl.defaultPrimary, + modelFast: sl.defaultFast, + validating: false, + error: null, + validated: false, + }; +} + +export function canSaveProvider( + form: Pick, +): boolean { + return form.apiKey.trim().length > 0 && form.validated && !form.validating; +} + +interface ValidateSnapshot { + provider: SupportedOnboardingProvider; + apiKey: string; + baseUrl: string; +} + +/** + * Pure reducer used by handleValidate — applies the validation result only when + * the current form still matches the snapshot taken before the async call. + * Exported for unit testing without a DOM. + */ +export function applyValidateResult( + current: AddProviderFormState, + snapshot: ValidateSnapshot, + ok: boolean, + message: string | undefined, +): AddProviderFormState { + if ( + current.provider !== snapshot.provider || + current.apiKey.trim() !== snapshot.apiKey || + current.baseUrl.trim() !== snapshot.baseUrl + ) { + // Form changed while we were waiting — discard the stale result. + return current; + } + if (ok) { + return { ...current, validating: false, validated: true }; + } + return { ...current, validating: false, error: message ?? 'Validation failed' }; +} + +function AddProviderModal({ + onSave, + onClose, +}: { + onSave: (rows: ProviderRow[]) => void; + onClose: () => void; +}) { + const providerOptions: { value: SupportedOnboardingProvider; label: string }[] = [ + { value: 'anthropic', label: 'Anthropic Claude' }, + { value: 'openai', label: 'OpenAI' }, + { value: 'openrouter', label: 'OpenRouter' }, + ]; + + const [form, setForm] = useState(makeDefaultForm('anthropic')); + + function setField(k: K, v: AddProviderFormState[K]) { + setForm((prev) => ({ ...prev, [k]: v, error: null, validated: false })); + } + + function handleProviderChange(p: string) { + if (!isSupportedOnboardingProvider(p)) return; + setForm(makeDefaultForm(p)); + } + + async function handleValidate() { + if (!window.codesign) return; + const snapshot = { + provider: form.provider, + apiKey: form.apiKey.trim(), + baseUrl: form.baseUrl.trim(), + }; + setForm((prev) => ({ ...prev, validating: true, error: null, validated: false })); + try { + const res = await window.codesign.settings.validateKey({ + provider: snapshot.provider, + apiKey: snapshot.apiKey, + ...(snapshot.baseUrl.length > 0 ? { baseUrl: snapshot.baseUrl } : {}), + }); + // Discard result if the user changed provider/key/baseUrl while we were waiting. + setForm((current) => + applyValidateResult(current, snapshot, res.ok, res.ok ? undefined : res.message), + ); + } finally { + // Ensure validating spinner clears even if we discarded the result. + setForm((current) => (current.validating ? { ...current, validating: false } : current)); + } + } + + async function handleSave() { + if (!window.codesign) return; + try { + const trimmedUrl = form.baseUrl.trim(); + const rows = await window.codesign.settings.addProvider({ + provider: form.provider, + apiKey: form.apiKey.trim(), + modelPrimary: form.modelPrimary, + modelFast: form.modelFast, + ...(trimmedUrl.length > 0 ? { baseUrl: trimmedUrl } : {}), + }); + onSave(rows); + } catch (err) { + setForm((prev) => ({ + ...prev, + error: err instanceof Error ? err.message : 'Save failed', + })); + } + } + + const sl = SHORTLIST[form.provider]; + const primaryOptions = sl.primary.map((m) => ({ value: m, label: m })); + const fastOptions = sl.fast.map((m) => ({ value: m, label: m })); + const canSave = canSaveProvider(form); + + return ( +
top-layer rendering interferes with our overlay stack + role="dialog" + aria-modal="true" + aria-label="Add provider" + className="fixed inset-0 z-[60] flex items-center justify-center p-6 bg-[var(--color-overlay)]" + onClick={onClose} + onKeyDown={(e) => e.key === 'Escape' && onClose()} + > +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="document" + > +
+

+ Add provider +

+ +
+ +
+
+

+ Provider +

+ +
+ +
+
+

+ API Key +

+ + Get key ↗ + +
+
+ setField('apiKey', v)} + placeholder="sk-..." + className="flex-1" + /> + +
+ {form.error && ( +

{form.error}

+ )} +
+ +
+

+ Base URL{' '} + (optional) +

+ setField('baseUrl', v)} + placeholder="https://your-proxy.example.com" + className="w-full" + /> +
+ +
+
+

+ Primary model +

+ setField('modelPrimary', v)} + options={primaryOptions} + /> +
+
+

+ Fast model +

+ setField('modelFast', v)} + options={fastOptions} + /> +
+
+
+ +
+ + +
+
+
+ ); +} + +function ProviderCard({ + row, + config, + onDelete, + onActivate, + onReEnterKey, +}: { + row: ProviderRow; + config: OnboardingState | null; + onDelete: (p: SupportedOnboardingProvider) => void; + onActivate: (p: SupportedOnboardingProvider) => void; + onReEnterKey: (p: SupportedOnboardingProvider) => void; +}) { + const label = SHORTLIST[row.provider]?.label ?? row.provider; + const [confirmDelete, setConfirmDelete] = useState(false); + const hasError = row.error !== undefined; + + return ( +
+
+
+
+ + {label} + + {row.isActive && !hasError && ( + + Active + + )} + {hasError && ( + + + Decryption failed + + )} +
+
+ {!hasError && ( + + {row.maskedKey} + + )} + {row.baseUrl && ( + + + {row.baseUrl} + + )} +
+
+ +
+ {!row.isActive && !hasError && ( + + )} + {hasError && ( + + )} + {confirmDelete ? ( +
+ + +
+ ) : ( + + )} +
+
+ + {row.isActive && !hasError && config !== null && ( + + )} +
+ ); +} + +function ActiveModelSelector({ + config, + provider, +}: { + config: OnboardingState; + provider: SupportedOnboardingProvider; +}) { + const sl = SHORTLIST[provider]; + const primaryOptions = sl.primary.map((m) => ({ value: m, label: m })); + const fastOptions = sl.fast.map((m) => ({ value: m, label: m })); + const setConfig = useCodesignStore((s) => s.completeOnboarding); + const pushToast = useCodesignStore((s) => s.pushToast); + + const [primary, setPrimary] = useState(config.modelPrimary ?? sl.defaultPrimary); + const [fast, setFast] = useState(config.modelFast ?? sl.defaultFast); + const saveTimeout = useRef | null>(null); + + useEffect(() => { + return () => { + if (saveTimeout.current !== null) { + clearTimeout(saveTimeout.current); + saveTimeout.current = null; + } + }; + }, []); + + async function save(p: string, f: string) { + if (!window.codesign) return; + try { + const next = await window.codesign.settings.setActiveProvider({ + provider, + modelPrimary: p, + modelFast: f, + }); + setConfig(next); + } catch (err) { + pushToast({ + variant: 'error', + title: 'Failed to save model selection', + description: err instanceof Error ? err.message : 'Unknown error', + }); + } + } + + function handlePrimaryChange(v: string) { + setPrimary(v); + if (saveTimeout.current !== null) clearTimeout(saveTimeout.current); + saveTimeout.current = setTimeout(() => void save(v, fast), 400); + } + + function handleFastChange(v: string) { + setFast(v); + if (saveTimeout.current !== null) clearTimeout(saveTimeout.current); + saveTimeout.current = setTimeout(() => void save(primary, v), 400); + } -function ModelsTab() { return ( -
-

- Providers -

-

- Provider key entry happens in the onboarding flow. To switch keys or add a new provider, - edit the config file directly — a richer UI lands once we have multiple providers stored. -

-
- Coming soon: in-place provider management for Anthropic, OpenAI, Google, OpenRouter. +
+
+

+ Primary +

+ +
+
+

+ Fast +

+
); } +function ModelsTab() { + const config = useCodesignStore((s) => s.config); + const setConfig = useCodesignStore((s) => s.completeOnboarding); + const pushToast = useCodesignStore((s) => s.pushToast); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [showAdd, setShowAdd] = useState(false); + const [reEnterProvider, setReEnterProvider] = useState(null); + + useEffect(() => { + if (!window.codesign) return; + void window.codesign.settings + .listProviders() + .then(setRows) + .catch((err) => { + pushToast({ + variant: 'error', + title: 'Failed to load providers', + description: err instanceof Error ? err.message : 'Unknown error', + }); + }) + .finally(() => setLoading(false)); + }, [pushToast]); + + async function handleDelete(provider: SupportedOnboardingProvider) { + if (!window.codesign) return; + try { + const next = await window.codesign.settings.deleteProvider(provider); + setRows(next); + const newState = await window.codesign.onboarding.getState(); + setConfig(newState); + pushToast({ variant: 'success', title: 'Provider removed' }); + } catch (err) { + pushToast({ + variant: 'error', + title: 'Delete failed', + description: err instanceof Error ? err.message : 'Unknown error', + }); + } + } + + async function handleActivate(provider: SupportedOnboardingProvider) { + if (!window.codesign) return; + const sl = SHORTLIST[provider]; + try { + const next = await window.codesign.settings.setActiveProvider({ + provider, + modelPrimary: sl.defaultPrimary, + modelFast: sl.defaultFast, + }); + setConfig(next); + const updatedRows = await window.codesign.settings.listProviders(); + setRows(updatedRows); + pushToast({ variant: 'success', title: `Switched to ${sl.label}` }); + } catch (err) { + pushToast({ + variant: 'error', + title: 'Switch failed', + description: err instanceof Error ? err.message : 'Unknown error', + }); + } + } + + function handleAddSave(nextRows: ProviderRow[]) { + setRows(nextRows); + setShowAdd(false); + setReEnterProvider(null); + pushToast({ variant: 'success', title: 'Provider saved' }); + } + + return ( + <> + {(showAdd || reEnterProvider !== null) && ( + { + setShowAdd(false); + setReEnterProvider(null); + }} + /> + )} + +
+
+ API Providers + +
+ + {loading && ( +
+ + Loading… +
+ )} + + {!loading && rows.length === 0 && ( +
+ No providers configured yet. Add one to start generating. +
+ )} + + {!loading && rows.length > 0 && ( +
+ {rows.map((row) => ( + + ))} +
+ )} +
+ + ); +} + +// ─── Appearance tab ─────────────────────────────────────────────────────────── + function AppearanceTab() { const theme = useCodesignStore((s) => s.theme); const setTheme = useCodesignStore((s) => s.setTheme); + const pushToast = useCodesignStore((s) => s.pushToast); + const [locale, setLocale] = useState('en'); + + useEffect(() => { + if (!window.codesign) return; + void window.codesign.locale + .getCurrent() + .then((l) => setLocale(l)) + .catch((err) => { + pushToast({ + variant: 'error', + title: 'Failed to load language', + description: err instanceof Error ? err.message : 'Unknown error', + }); + }); + }, [pushToast]); + + async function handleLocaleChange(v: string) { + if (!window.codesign) return; + setLocale(v); + try { + await window.codesign.locale.set(v); + } catch (err) { + pushToast({ + variant: 'error', + title: 'Failed to save language', + description: err instanceof Error ? err.message : 'Unknown error', + }); + } + } + return ( -
+
-

- Theme -

-

+ Theme +

Choice persists across restarts.

+
- {(['light', 'dark'] as const).map((t) => { - const active = theme === t; + {( + [ + { value: 'light', label: 'Light', desc: 'Warm beige, soft shadows' }, + { value: 'dark', label: 'Dark', desc: 'Deep neutral, low glare' }, + ] as const + ).map((t) => { + const active = theme === t.value; return ( ); })}
+ +
+ + + +
+
+ ); +} + +// ─── Storage tab ────────────────────────────────────────────────────────────── + +function CopyButton({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + async function handleCopy() { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + return ( + + ); +} + +function PathRow({ label, value, onOpen }: { label: string; value: string; onOpen: () => void }) { + return ( +
+
+ +
+ + +
+
+ + {value} +
); } function StorageTab() { + const pushToast = useCodesignStore((s) => s.pushToast); + const closeSettings = useCodesignStore((s) => s.closeSettings); + const completeOnboarding = useCodesignStore((s) => s.completeOnboarding); + const [paths, setPaths] = useState(null); + const [confirmReset, setConfirmReset] = useState(false); + + useEffect(() => { + if (!window.codesign) return; + void window.codesign.settings + .getPaths() + .then(setPaths) + .catch((err) => { + pushToast({ + variant: 'error', + title: 'Failed to load app paths', + description: err instanceof Error ? err.message : 'Unknown error', + }); + }); + }, [pushToast]); + + async function openFolder(path: string) { + try { + await window.codesign?.settings.openFolder(path); + } catch (err) { + pushToast({ + variant: 'error', + title: 'Could not open folder', + description: err instanceof Error ? err.message : 'Unknown error', + }); + } + } + + async function handleReset() { + if (!window.codesign) return; + await window.codesign.settings.resetOnboarding(); + const newState = await window.codesign.onboarding.getState(); + completeOnboarding(newState); + closeSettings(); + pushToast({ variant: 'info', title: 'Onboarding reset. Restart the app to re-run setup.' }); + setConfirmReset(false); + } + return ( -
-
-

- Config file -

-

- All settings live in a single TOML file you can read or edit yourself. +

+ Paths + + {paths === null ? ( +
+ + Loading… +
+ ) : ( +
+ openFolder(paths.configFolder)} + /> + openFolder(paths.logsFolder)} /> + openFolder(paths.data)} + /> +
+ )} + +
+ Onboarding +

+ Clear the setup flag so the onboarding wizard runs again on next launch.

+ + {confirmReset ? ( +
+ + This will remove your saved keys. Continue? + + + +
+ ) : ( + + )}
- - {CONFIG_PATH} - -

- Designs and history are stored locally in SQLite under the same directory. -

); } +// ─── Advanced tab ───────────────────────────────────────────────────────────── + function AdvancedTab() { + const pushToast = useCodesignStore((s) => s.pushToast); + const [prefs, setPrefs] = useState({ + updateChannel: 'stable', + generationTimeoutSec: 120, + }); + + useEffect(() => { + if (!window.codesign) return; + void window.codesign.preferences + .get() + .then(setPrefs) + .catch((err) => { + pushToast({ + variant: 'error', + title: 'Failed to load preferences', + description: err instanceof Error ? err.message : 'Unknown error', + }); + }); + }, [pushToast]); + + async function updatePref(patch: Partial) { + if (!window.codesign) return; + try { + const next = await window.codesign.preferences.update(patch); + setPrefs(next); + } catch (err) { + pushToast({ + variant: 'error', + title: 'Failed to save preference', + description: err instanceof Error ? err.message : 'Unknown error', + }); + } + } + + async function handleDevtools() { + if (!window.codesign) return; + try { + await window.codesign.settings.toggleDevtools(); + } catch (err) { + pushToast({ + variant: 'error', + title: 'Could not toggle DevTools', + description: err instanceof Error ? err.message : 'Unknown error', + }); + } + } + return ( -
- Advanced options will appear here as the app grows. Nothing to configure yet. +
+ + void updatePref({ updateChannel: v })} + /> + + + + void updatePref({ generationTimeoutSec: Number(v) })} + options={[ + { value: '60', label: '60 s' }, + { value: '120', label: '120 s' }, + { value: '180', label: '180 s' }, + { value: '300', label: '300 s' }, + ]} + /> + + + + +
); } +// ─── Shell ──────────────────────────────────────────────────────────────────── + export function Settings() { const open = useCodesignStore((s) => s.settingsOpen); const close = useCodesignStore((s) => s.closeSettings); - const [tab, setTab] = useState('appearance'); + const [tab, setTab] = useState('models'); + if (!open) return null; + return (
top-layer rendering interferes with our overlay stack @@ -119,19 +1111,19 @@ export function Settings() { }} >
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} role="document" > -
-
+
+

{tab}

@@ -163,7 +1155,7 @@ export function Settings() {
-
+
{tab === 'models' ? : null} {tab === 'appearance' ? : null} {tab === 'storage' ? : null} diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts deleted file mode 100644 index 4a482b34..00000000 --- a/packages/shared/src/config.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ConfigSchema, STORED_DESIGN_SYSTEM_SCHEMA_VERSION } from './config'; - -describe('ConfigSchema', () => { - it('upgrades legacy designSystem snapshots to the versioned format', () => { - const parsed = ConfigSchema.parse({ - version: 1, - provider: 'openai', - modelPrimary: 'gpt-4o', - modelFast: 'gpt-4o-mini', - secrets: {}, - baseUrls: {}, - designSystem: { - rootPath: '/repo', - summary: 'Warm neutral tokens', - extractedAt: '2026-04-18T00:00:00.000Z', - sourceFiles: ['tailwind.config.ts'], - colors: ['#f4efe8'], - fonts: ['IBM Plex Sans'], - spacing: ['1rem'], - radius: ['18px'], - shadows: ['0 12px 40px rgba(0,0,0,0.12)'], - }, - }); - - expect(parsed.designSystem?.schemaVersion).toBe(STORED_DESIGN_SYSTEM_SCHEMA_VERSION); - expect(parsed.designSystem?.rootPath).toBe('/repo'); - }); -});