Skip to content

Commit 7109ffb

Browse files
committed
feat(desktop): implement settings tabs with multi-provider management
Replace all four "Coming soon" stub tabs with fully functional settings UI. Models tab: list saved API providers with masked keys and base URLs, add new providers via a modal with inline key validation, switch active provider, configure per-provider primary/fast model selection, and delete providers with an inline confirm step. Appearance tab: light/dark theme segmented control plus language selector. Storage tab: display config / log / data-dir paths with copy and open-folder buttons; reset-onboarding action with confirmation. Advanced tab: update channel selector, generation timeout input, and a toggle-DevTools button. New IPC channels added to onboarding-ipc.ts: 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 New preload surface: window.codesign.settings.*
1 parent 2575478 commit 7109ffb

3 files changed

Lines changed: 1073 additions & 52 deletions

File tree

apps/desktop/src/main/onboarding-ipc.ts

Lines changed: 173 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import {
66
type SupportedOnboardingProvider,
77
isSupportedOnboardingProvider,
88
} from '@open-codesign/shared';
9-
import { ipcMain } from 'electron';
10-
import { readConfig, writeConfig } from './config';
9+
import { ipcMain, shell } from 'electron';
10+
import { configDir, configPath, readConfig, writeConfig } from './config';
1111
import { decryptSecret, encryptSecret } from './keychain';
12+
import { getLogPath } from './logger';
1213

1314
interface SaveKeyInput {
1415
provider: SupportedOnboardingProvider;
@@ -24,6 +25,14 @@ interface ValidateKeyInput {
2425
baseUrl?: string;
2526
}
2627

28+
/** Summarised row returned to the renderer for each stored provider. */
29+
export interface ProviderRow {
30+
provider: SupportedOnboardingProvider;
31+
maskedKey: string;
32+
baseUrl: string | null;
33+
isActive: boolean;
34+
}
35+
2736
let cachedConfig: Config | null = null;
2837
let configLoaded = false;
2938

@@ -61,6 +70,13 @@ export function getBaseUrlForProvider(provider: string): string | undefined {
6170
return ref?.baseUrl;
6271
}
6372

73+
function maskKey(plain: string): string {
74+
if (plain.length <= 8) return '***';
75+
const prefix = plain.startsWith('sk-') ? 'sk-' : plain.slice(0, 4);
76+
const suffix = plain.slice(-4);
77+
return `${prefix}***${suffix}`;
78+
}
79+
6480
function toState(cfg: Config | null): OnboardingState {
6581
if (cfg === null) {
6682
return { hasKey: false, provider: null, modelPrimary: null, modelFast: null, baseUrl: null };
@@ -87,6 +103,27 @@ function toState(cfg: Config | null): OnboardingState {
87103
};
88104
}
89105

106+
function toProviderRows(cfg: Config | null): ProviderRow[] {
107+
if (cfg === null) return [];
108+
const rows: ProviderRow[] = [];
109+
for (const [p, ref] of Object.entries(cfg.secrets)) {
110+
if (!isSupportedOnboardingProvider(p) || ref === undefined) continue;
111+
let plain: string;
112+
try {
113+
plain = decryptSecret(ref.ciphertext);
114+
} catch {
115+
plain = '';
116+
}
117+
rows.push({
118+
provider: p,
119+
maskedKey: maskKey(plain),
120+
baseUrl: cfg.baseUrls?.[p as SupportedOnboardingProvider]?.baseUrl ?? null,
121+
isActive: cfg.provider === p,
122+
});
123+
}
124+
return rows;
125+
}
126+
90127
function parseSaveKey(raw: unknown): SaveKeyInput {
91128
if (typeof raw !== 'object' || raw === null) {
92129
throw new CodesignError('save-key expects an object payload', 'IPC_BAD_INPUT');
@@ -186,4 +223,138 @@ export function registerOnboardingIpc(): void {
186223
ipcMain.handle('onboarding:skip', async (): Promise<OnboardingState> => {
187224
return toState(cachedConfig);
188225
});
226+
227+
// ── Settings: provider management ──────────────────────────────────────────
228+
229+
ipcMain.handle('settings:list-providers', (): ProviderRow[] => {
230+
return toProviderRows(getCachedConfig());
231+
});
232+
233+
ipcMain.handle('settings:add-provider', async (_e, raw: unknown): Promise<ProviderRow[]> => {
234+
const input = parseSaveKey(raw);
235+
const ciphertext = encryptSecret(input.apiKey);
236+
const nextBaseUrls = { ...(cachedConfig?.baseUrls ?? {}) };
237+
if (input.baseUrl !== undefined) {
238+
nextBaseUrls[input.provider] = { baseUrl: input.baseUrl };
239+
} else {
240+
delete nextBaseUrls[input.provider];
241+
}
242+
// When adding the first provider, make it active.
243+
const activeProvider =
244+
cachedConfig !== null && isSupportedOnboardingProvider(cachedConfig.provider)
245+
? cachedConfig.provider
246+
: input.provider;
247+
const next: Config = {
248+
version: 1,
249+
provider: activeProvider,
250+
modelPrimary: cachedConfig?.modelPrimary ?? input.modelPrimary,
251+
modelFast: cachedConfig?.modelFast ?? input.modelFast,
252+
secrets: {
253+
...(cachedConfig?.secrets ?? {}),
254+
[input.provider]: { ciphertext },
255+
},
256+
baseUrls: nextBaseUrls,
257+
};
258+
await writeConfig(next);
259+
cachedConfig = next;
260+
return toProviderRows(cachedConfig);
261+
});
262+
263+
ipcMain.handle('settings:delete-provider', async (_e, raw: unknown): Promise<ProviderRow[]> => {
264+
if (typeof raw !== 'string' || !isSupportedOnboardingProvider(raw)) {
265+
throw new CodesignError('delete-provider expects a provider string', 'IPC_BAD_INPUT');
266+
}
267+
const cfg = getCachedConfig();
268+
if (cfg === null) return [];
269+
const nextSecrets = { ...cfg.secrets };
270+
delete nextSecrets[raw];
271+
const nextBaseUrls = { ...(cfg.baseUrls ?? {}) };
272+
delete nextBaseUrls[raw];
273+
// If we deleted the active provider, switch to first remaining one.
274+
const remaining = Object.keys(nextSecrets).filter(isSupportedOnboardingProvider);
275+
const nextActive = cfg.provider === raw ? (remaining[0] ?? 'openai') : cfg.provider;
276+
if (!isSupportedOnboardingProvider(nextActive)) {
277+
throw new CodesignError('No valid active provider after deletion', 'PROVIDER_NOT_SUPPORTED');
278+
}
279+
const next: Config = {
280+
version: 1,
281+
provider: nextActive,
282+
modelPrimary: cfg.modelPrimary,
283+
modelFast: cfg.modelFast,
284+
secrets: nextSecrets,
285+
baseUrls: nextBaseUrls,
286+
};
287+
await writeConfig(next);
288+
cachedConfig = next;
289+
return toProviderRows(cachedConfig);
290+
});
291+
292+
ipcMain.handle(
293+
'settings:set-active-provider',
294+
async (_e, raw: unknown): Promise<OnboardingState> => {
295+
if (typeof raw !== 'object' || raw === null) {
296+
throw new CodesignError('set-active-provider expects an object', 'IPC_BAD_INPUT');
297+
}
298+
const r = raw as Record<string, unknown>;
299+
const provider = r['provider'];
300+
const modelPrimary = r['modelPrimary'];
301+
const modelFast = r['modelFast'];
302+
if (typeof provider !== 'string' || !isSupportedOnboardingProvider(provider)) {
303+
throw new CodesignError('provider must be a supported provider string', 'IPC_BAD_INPUT');
304+
}
305+
if (typeof modelPrimary !== 'string' || modelPrimary.trim().length === 0) {
306+
throw new CodesignError('modelPrimary must be a non-empty string', 'IPC_BAD_INPUT');
307+
}
308+
if (typeof modelFast !== 'string' || modelFast.trim().length === 0) {
309+
throw new CodesignError('modelFast must be a non-empty string', 'IPC_BAD_INPUT');
310+
}
311+
const cfg = getCachedConfig();
312+
if (cfg === null) {
313+
throw new CodesignError('No configuration found', 'CONFIG_MISSING');
314+
}
315+
const next: Config = {
316+
...cfg,
317+
provider,
318+
modelPrimary,
319+
modelFast,
320+
};
321+
await writeConfig(next);
322+
cachedConfig = next;
323+
return toState(cachedConfig);
324+
},
325+
);
326+
327+
// ── Settings: storage helpers ───────────────────────────────────────────────
328+
329+
ipcMain.handle('settings:get-paths', () => ({
330+
config: configPath(),
331+
logs: getLogPath(),
332+
data: configDir(),
333+
}));
334+
335+
ipcMain.handle('settings:open-folder', async (_e, raw: unknown) => {
336+
if (typeof raw !== 'string') {
337+
throw new CodesignError('open-folder expects a path string', 'IPC_BAD_INPUT');
338+
}
339+
await shell.openPath(raw);
340+
});
341+
342+
ipcMain.handle('settings:reset-onboarding', async (): Promise<void> => {
343+
const cfg = getCachedConfig();
344+
if (cfg === null) return;
345+
// Clear secrets so onboarding flow triggers again on next load.
346+
const next: Config = {
347+
...cfg,
348+
secrets: {},
349+
};
350+
await writeConfig(next);
351+
cachedConfig = next;
352+
});
353+
354+
// ── Settings: appearance / advanced ────────────────────────────────────────
355+
356+
ipcMain.handle('settings:toggle-devtools', (_e) => {
357+
// We need the webContents reference — the event sender is the renderer.
358+
_e.sender.toggleDevTools();
359+
});
189360
}

apps/desktop/src/preload/index.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ export interface ExportInvokeResponse {
2323
bytes?: number;
2424
}
2525

26+
export interface ProviderRow {
27+
provider: SupportedOnboardingProvider;
28+
maskedKey: string;
29+
baseUrl: string | null;
30+
isActive: boolean;
31+
}
32+
33+
export interface AppPaths {
34+
config: string;
35+
logs: string;
36+
data: string;
37+
}
38+
2639
const api = {
2740
detectProvider: (key: string) =>
2841
ipcRenderer.invoke('codesign:detect-provider', key) as Promise<string | null>,
@@ -61,6 +74,35 @@ const api = {
6174
}) => ipcRenderer.invoke('onboarding:save-key', input) as Promise<OnboardingState>,
6275
skip: () => ipcRenderer.invoke('onboarding:skip') as Promise<OnboardingState>,
6376
},
77+
settings: {
78+
listProviders: () => ipcRenderer.invoke('settings:list-providers') as Promise<ProviderRow[]>,
79+
addProvider: (input: {
80+
provider: SupportedOnboardingProvider;
81+
apiKey: string;
82+
modelPrimary: string;
83+
modelFast: string;
84+
baseUrl?: string;
85+
}) => ipcRenderer.invoke('settings:add-provider', input) as Promise<ProviderRow[]>,
86+
deleteProvider: (provider: SupportedOnboardingProvider) =>
87+
ipcRenderer.invoke('settings:delete-provider', provider) as Promise<ProviderRow[]>,
88+
setActiveProvider: (input: {
89+
provider: SupportedOnboardingProvider;
90+
modelPrimary: string;
91+
modelFast: string;
92+
}) => ipcRenderer.invoke('settings:set-active-provider', input) as Promise<OnboardingState>,
93+
getPaths: () => ipcRenderer.invoke('settings:get-paths') as Promise<AppPaths>,
94+
openFolder: (path: string) => ipcRenderer.invoke('settings:open-folder', path) as Promise<void>,
95+
resetOnboarding: () => ipcRenderer.invoke('settings:reset-onboarding') as Promise<void>,
96+
toggleDevtools: () => ipcRenderer.invoke('settings:toggle-devtools') as Promise<void>,
97+
validateKey: (input: {
98+
provider: SupportedOnboardingProvider;
99+
apiKey: string;
100+
baseUrl?: string;
101+
}) =>
102+
ipcRenderer.invoke('onboarding:validate-key', input) as Promise<
103+
ValidateKeyResult | ValidateKeyError
104+
>,
105+
},
64106
};
65107

66108
contextBridge.exposeInMainWorld('codesign', api);

0 commit comments

Comments
 (0)