@@ -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' ;
1111import { decryptSecret , encryptSecret } from './keychain' ;
12+ import { getLogPath } from './logger' ;
1213
1314interface 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+
2736let cachedConfig : Config | null = null ;
2837let 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+
6480function 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+
90127function 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}
0 commit comments