diff --git a/SPEC.md b/SPEC.md index d8a69de..ec78d88 100644 --- a/SPEC.md +++ b/SPEC.md @@ -68,7 +68,9 @@ electron-updater publishes to GitHub Releases under `PowerInterviewAI/client` (c ## Privacy Model -- CV, job description, and config stay on the user's device (Electron Store) +- Interview configuration (full name, CV/profile, context) is stored on the backend against the + user's account so it follows them across devices - it is not kept on local disk +- Device-specific settings (audio device, window bounds, scroll preferences) stay on the device - Only the minimum data needed for AI suggestions is sent to the backend - No transcript storage on external servers - Credentials encrypted via Electron Store diff --git a/package.json b/package.json index 36b587f..012430a 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "electron:build": "pnpm electron:build-main && pnpm build && electron-builder", "lint": "eslint .", "format": "prettier --write . && eslint --fix .", + "test:main": "pnpm electron:build-main && node test/run.mjs", "start": "pnpm electron:dev-hide" }, "dependencies": { diff --git a/src/main/api/client.ts b/src/main/api/client.ts index 5665e9a..5cdc6ae 100644 --- a/src/main/api/client.ts +++ b/src/main/api/client.ts @@ -51,9 +51,13 @@ export class ApiClient { delete this.headers['Authorization']; } - async get(path: string, params?: Record): Promise> { + async get( + path: string, + params?: Record, + timeoutMs?: number + ): Promise> { const url = this.buildUrl(path, params); - return this.request('GET', url); + return this.request('GET', url, undefined, timeoutMs); } async postFormData(path: string, formData: FormData): Promise> { @@ -103,9 +107,9 @@ export class ApiClient { } } - async post(path: string, body?: unknown): Promise> { + async post(path: string, body?: unknown, timeoutMs?: number): Promise> { const url = this.buildUrl(path); - return this.request('POST', url, body); + return this.request('POST', url, body, timeoutMs); } async postStream(path: string, body?: unknown): Promise | null> { @@ -118,12 +122,22 @@ export class ApiClient { return this.request('PUT', url, body); } + async patch(path: string, body?: unknown, timeoutMs?: number): Promise> { + const url = this.buildUrl(path); + return this.request('PATCH', url, body, timeoutMs); + } + async delete(path: string): Promise> { const url = this.buildUrl(path); return this.request('DELETE', url); } - private async request(method: string, url: string, body?: unknown): Promise> { + private async request( + method: string, + url: string, + body?: unknown, + timeoutMs?: number + ): Promise> { try { const sessionToken = configStore.getConfig().sessionToken; if (sessionToken) { @@ -134,6 +148,7 @@ export class ApiClient { method, headers: this.headers, body: body ? JSON.stringify(body) : undefined, + signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined, }); const respBody = await response.json().catch(() => ({})); @@ -154,11 +169,16 @@ export class ApiClient { data: respBody, }; } catch (error: unknown) { + const timedOut = error instanceof Error && error.name === 'TimeoutError'; return { status: 0, error: { - code: 'NETWORK_ERROR', - message: error instanceof Error ? error.message : 'Network request failed', + code: timedOut ? 'TIMEOUT' : 'NETWORK_ERROR', + message: timedOut + ? 'The request timed out' + : error instanceof Error + ? error.message + : 'Network request failed', }, }; } diff --git a/src/main/api/health-check.ts b/src/main/api/health-check.ts index 95a478e..8a7d361 100644 --- a/src/main/api/health-check.ts +++ b/src/main/api/health-check.ts @@ -7,12 +7,21 @@ import { RunningState } from '../types/app-state.js'; import { ClientPingRequest, ClientPingResponse } from '../types/health-check.js'; import { ApiClient, ApiResponse } from './client.js'; +// The initial pingClient() is awaited before the liveness and 401 loops start, and +// HealthCheckService.start() is itself awaited during app startup, so without a bound here a +// single stalled socket holds both back for as long as the runtime's default allows. +// +// Generous rather than tight: the loops await each ping and only then sleep, so this never +// overlaps a tick, and ping-client authenticates against the database. Cutting it close to the +// 5s interval would report a slow-but-alive backend as down on a high-latency connection. +const PING_TIMEOUT_MS = 10_000; + export class HealthCheckApi extends ApiClient { /** * Health check / ping */ async ping(): Promise> { - return this.get('/api/health-check/ping'); + return this.get('/api/health-check/ping', undefined, PING_TIMEOUT_MS); } /** @@ -20,8 +29,12 @@ export class HealthCheckApi extends ApiClient { */ async pingClient(): Promise> { const appState = appStateService.getState(); - return this.post('/api/health-check/ping-client', { - is_assistant_running: appState.runningState === RunningState.Running, - } as ClientPingRequest); + return this.post( + '/api/health-check/ping-client', + { + is_assistant_running: appState.runningState === RunningState.Running, + } as ClientPingRequest, + PING_TIMEOUT_MS + ); } } diff --git a/src/main/api/users.ts b/src/main/api/users.ts new file mode 100644 index 0000000..802eec3 --- /dev/null +++ b/src/main/api/users.ts @@ -0,0 +1,28 @@ +/** + * Users API + * Handles the authenticated user's account and interview configuration + * (full name, profile, context) + */ + +import { UpdateInterviewConfigRequest, UserAccount } from '../types/account.js'; +import { ApiClient, ApiResponse } from './client.js'; + +// These carry the full profile/context payload, so allow well over a plain JSON round-trip, +// but never let a stalled socket leave the request pending forever. +const REQUEST_TIMEOUT_MS = 30_000; + +export class UsersApi extends ApiClient { + /** + * Get the authenticated user's account + */ + async getMe(): Promise> { + return this.get('/api/users/me', undefined, REQUEST_TIMEOUT_MS); + } + + /** + * Replace the authenticated user's interview configuration + */ + async updateInterviewConfig(data: UpdateInterviewConfigRequest): Promise> { + return this.patch('/api/users/me/interview-config', data, REQUEST_TIMEOUT_MS); + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 04c9fba..b6ea33e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -8,6 +8,7 @@ const __dirname = path.dirname(__filename); // Import modules import { MIN_HEIGHT, MIN_WIDTH } from './consts.js'; import { registerGlobalHotkeys, unregisterHotkeys } from './hotkeys.js'; +import { registerAccountHandlers } from './ipc/account.js'; import { registerAppStateHandlers } from './ipc/app-state.js'; import { registerAuthHandlers } from './ipc/auth.js'; import { registerAutoUpdaterHandlers } from './ipc/auto-updater.js'; @@ -182,6 +183,7 @@ app.whenReady().then(async () => { registerConfigHandlers(); registerAppStateHandlers(); registerAuthHandlers(); + registerAccountHandlers(); registerPaymentHandlers(); registerLLMHandlers(); registerPermissionHandlers(); diff --git a/src/main/ipc/account.ts b/src/main/ipc/account.ts new file mode 100644 index 0000000..7d40356 --- /dev/null +++ b/src/main/ipc/account.ts @@ -0,0 +1,24 @@ +import { ipcMain } from 'electron'; + +import { accountService } from '../services/account.service.js'; + +export function registerAccountHandlers(): void { + // Push local account config changes (full name, profile, context) to the backend + ipcMain.handle( + 'account:update', + async (_event, fullName: string, profileData: string, context: string) => { + return accountService.updateConfig(fullName, profileData, context); + } + ); + + // Retry the initial pull when it failed at startup (offline, backend down) + ipcMain.handle('account:refresh', async () => { + return accountService.pullFromBackend(); + }); + + // Full config for the configuration dialog; kept out of the app-state broadcast because + // the profile and context can run to hundreds of KB. + ipcMain.handle('account:get', async () => { + return accountService.getEditableConfig(); + }); +} diff --git a/src/main/ipc/app-state.ts b/src/main/ipc/app-state.ts index b7221db..a515db4 100644 --- a/src/main/ipc/app-state.ts +++ b/src/main/ipc/app-state.ts @@ -9,12 +9,12 @@ import { appStateService } from '../services/app-state.service.js'; export function registerAppStateHandlers(): void { // Get current app state ipcMain.handle('app:get-state', async () => { - return appStateService.getState(); + return appStateService.getRendererState(); }); // Update app state ipcMain.handle('app:update-state', async (_event, updates) => { appStateService.updateState(updates); - return appStateService.getState(); + return appStateService.getRendererState(); }); } diff --git a/src/main/preload.cts b/src/main/preload.cts index 01a7a47..3dbede2 100644 --- a/src/main/preload.cts +++ b/src/main/preload.cts @@ -53,6 +53,13 @@ const electronApi = { ipcRenderer.invoke('auth:change-password', currentPassword, newPassword), }, + account: { + update: (fullName: string, profileData: string, context: string) => + ipcRenderer.invoke('account:update', fullName, profileData, context), + refresh: () => ipcRenderer.invoke('account:refresh'), + get: () => ipcRenderer.invoke('account:get'), + }, + payment: { getPlans: () => ipcRenderer.invoke('payment:get-plans'), getCurrencies: () => ipcRenderer.invoke('payment:get-currencies'), diff --git a/src/main/services/account.service.ts b/src/main/services/account.service.ts new file mode 100644 index 0000000..53b51fc --- /dev/null +++ b/src/main/services/account.service.ts @@ -0,0 +1,225 @@ +import { UsersApi } from '../api/users.js'; +import { + claimLegacyInterviewConf, + clearLegacyInterviewConf, + getLegacyInterviewConf, + getLegacyInterviewConfOwner, +} from '../store/config.store.js'; +import { InterviewConfig } from '../types/app-state.js'; +import { appStateService } from './app-state.service.js'; + +/** + * AccountService + * Keeps the in-memory interview config (full name, profile, context) in sync with + * the account persisted on the backend. Not written to local disk - the backend is + * the only durable store, so this always reflects whatever was last fetched/saved + * this session. + */ +export class AccountService { + private client = new UsersApi(); + + /** Bumped by every write to the config in app state. See `applyIfCurrent`. */ + private generation = 0; + + /** + * Write a config read into app state, unless something newer landed while it was in flight. + * + * The startup pull is deliberately unawaited and can take up to the 30s request timeout, so a + * save from the dialog, a logout, or a second pull can all land first. Applying the older read + * afterwards shows nothing: the dialog is closed by then, and the suggestion services read the + * config straight out of app state, so the rest of the session would run on the stale CV. + * + * Writes the caller already knows to be authoritative (`updateConfig`, `clearState`) bump the + * generation directly instead of going through here. + */ + private applyIfCurrent(generation: number, config: InterviewConfig, loaded: boolean): boolean { + if (generation !== this.generation) return false; + + this.generation++; + appStateService.updateState({ interviewConfig: config, interviewConfigLoaded: loaded }); + return true; + } + + /** + * Pull the authenticated user's persisted interview config from the backend + * into app state. Called after login so a device shows the same config the + * user last saved anywhere. + */ + async pullFromBackend(): Promise<{ success: boolean; error?: string }> { + const generation = this.generation; + try { + const response = await this.client.getMe(); + if (response.error || !response.data) { + return { success: false, error: response.error?.message || 'Failed to fetch account' }; + } + + const account = response.data; + const interviewConfig = account.interview_config; + + // Pre-sync builds kept this config on local disk only. If the account has none yet, + // adopt the leftover local copy instead of presenting the user an empty profile. + if (!interviewConfig) { + const migration = await this.migrateLegacyConfig(account._id, generation); + if (migration === 'migrated') return { success: true }; + if (migration === 'failed') { + return { success: false, error: 'Failed to migrate local configuration' }; + } + } + + // Success either way: the fetch itself worked, and if this read was superseded then what + // is in state is newer than what this response carried. + this.applyIfCurrent( + generation, + { + fullName: interviewConfig?.full_name ?? '', + profileData: interviewConfig?.profile_data ?? '', + context: interviewConfig?.context ?? '', + }, + true + ); + if (interviewConfig) this.discardLegacyConfigIfOwned(account._id); + return { success: true }; + } catch { + return { success: false, error: 'Failed to fetch account' }; + } + } + + /** + * Push a pre-sync local config up to the account, once. The local copy is only + * dropped after the backend confirms the write, so a failed migration is retried + * on the next launch rather than losing the user's profile. + * + * 'none' means there was nothing local to migrate, which is distinct from a push + * that failed: only the former should leave the user looking at a blank profile. + * + * The leftover copy is device-scoped, not account-scoped, so the first account offered + * it claims it and it is never pushed anywhere else. Without that claim, a second + * sign-in - a new signup, or another user on a shared machine - would inherit the first + * user's CV, because a failed push deliberately keeps the copy around for retry. The + * claim is persisted, since that retry can happen in a later run of the app. + */ + private async migrateLegacyConfig( + accountId: string, + generation: number + ): Promise<'migrated' | 'failed' | 'none'> { + const legacy = getLegacyInterviewConf(); + const fullName = legacy?.username ?? ''; + const profileData = legacy?.profileData ?? ''; + const context = legacy?.jobDescription ?? ''; + if (!fullName && !profileData && !context) return 'none'; + + const owner = getLegacyInterviewConfOwner(); + if (owner === null) { + claimLegacyInterviewConf(accountId); + } else if (owner !== accountId) { + return 'none'; + } + + const result = await this.updateConfig(fullName, profileData, context); + if (!result.success) { + // Surface the local copy rather than an empty form, but leave it unloaded so the + // dialog keeps Save disabled and retries the pull instead of letting the user + // overwrite the account from a half-migrated state. Guarded like any other read: two + // pulls can both reach this on first launch after upgrade, and a failed one must not + // put Save back behind a lock the successful one just released. + this.applyIfCurrent(generation, { fullName, profileData, context }, false); + return 'failed'; + } + + clearLegacyInterviewConf(); + return 'migrated'; + } + + /** + * Drop the pre-sync copy now that this account carries its own config. + * + * Gated on the claim: an unclaimed copy belongs to the first account offered it, same rule + * migrateLegacyConfig applies, so this account may discard it. A copy claimed by a different + * account is a migration that has not finished - a failed push deliberately keeps it for + * retry - and deleting it here would lose that user's CV on a shared machine. + */ + private discardLegacyConfigIfOwned(accountId: string): void { + const owner = getLegacyInterviewConfOwner(); + if (owner !== null && owner !== accountId) return; + clearLegacyInterviewConf(); + } + + /** + * Push local interview config changes to the backend, then mirror the + * saved values into app state. + */ + async updateConfig( + fullName: string, + profileData: string, + context: string + ): Promise<{ success: boolean; error?: string }> { + try { + const response = await this.client.updateInterviewConfig({ + full_name: fullName, + profile_data: profileData, + context, + }); + if (response.error) { + return { success: false, error: response.error.message || 'Failed to update account' }; + } + + // Mirror what the backend stored, not what was sent: it truncates oversized fields, + // so echoing the request would leave the app showing a value the account does not have. + const saved = response.data?.interview_config; + this.generation++; + appStateService.updateState({ + interviewConfig: { + fullName: saved?.full_name ?? fullName, + profileData: saved?.profile_data ?? profileData, + context: saved?.context ?? context, + }, + interviewConfigLoaded: true, + }); + return { success: true }; + } catch { + return { success: false, error: 'Failed to update account' }; + } + } + + /** + * Full interview config for the configuration dialog. + * + * Always refreshes first: a save replaces the whole config, so editing a copy another device + * has since changed would silently discard that change. `success` reports whether the values + * are safe to save over, and requires *this* refresh to have succeeded - not just that + * something was loaded earlier in the session. `interviewConfigLoaded` survives a failed pull + * by design (it gates Start, which should keep working on a blip), so trusting it alone would + * hand the dialog a stale copy with Save enabled and no warning, which is exactly how a newer + * config saved on another device gets overwritten. + */ + async getEditableConfig(): Promise<{ + success: boolean; + data: InterviewConfig; + error?: string; + }> { + const pull = await this.pullFromBackend(); + const state = appStateService.getState(); + const fresh = pull.success && state.interviewConfigLoaded; + + return { + success: fresh, + data: state.interviewConfig, + error: fresh ? undefined : pull.error || 'Failed to load configuration', + }; + } + + /** + * Drop the signed-out account's config from memory. Nothing else resets it, so + * without this the next user on this device inherits the previous user's profile + * whenever their post-login pull fails, and can overwrite their own account with it. + */ + clearState(): void { + this.generation++; + appStateService.updateState({ + interviewConfig: { fullName: '', profileData: '', context: '' }, + interviewConfigLoaded: false, + }); + } +} + +export const accountService = new AccountService(); diff --git a/src/main/services/app-state.service.ts b/src/main/services/app-state.service.ts index 70f4095..a8be33c 100644 --- a/src/main/services/app-state.service.ts +++ b/src/main/services/app-state.service.ts @@ -7,6 +7,7 @@ import { ActionSuggestion, AppState, LiveSuggestion, + RendererAppState, RunningState, Speaker, SuggestionState, @@ -24,6 +25,8 @@ const DEFAULT_STATE: AppState = { credits: undefined, userRole: undefined, providedLLMModel: undefined, + interviewConfig: { fullName: '', profileData: '', context: '' }, + interviewConfigLoaded: false, }; export class AppStateService { @@ -75,19 +78,40 @@ export class AppStateService { return { ...this.state }; } + /** + * The state as the renderer sees it. Keeps the CV and job description out of IPC: they can + * run to hundreds of KB and every state change broadcasts the whole object. + */ + getRendererState(): RendererAppState { + const { interviewConfig, ...rest } = this.state; + return { + ...rest, + interviewConfig: { + fullName: interviewConfig.fullName, + hasProfileData: interviewConfig.profileData.trim() !== '', + }, + }; + } + updateState(updates: Partial): AppState { + // The health-check loops re-report identical values every 1-5s. Broadcasting those would + // re-render every subscriber for nothing, so only notify when something actually moved. + const changed = (Object.keys(updates) as (keyof AppState)[]).some( + (key) => !Object.is(this.state[key], updates[key]) + ); + this.state = { ...this.state, ...updates }; - const s = this.getState(); - // broadcast update to renderer if window available - this.notifyRenderer(); - return s; + if (changed) { + this.notifyRenderer(); + } + return this.getState(); } private notifyRenderer(): void { try { const win = getWindowReference(); if (win && !win.isDestroyed()) { - win.webContents.send('app:state-updated', this.getState()); + win.webContents.send('app:state-updated', this.getRendererState()); } } catch (e) { console.warn('Failed to broadcast app state update:', e); diff --git a/src/main/services/auth.service.ts b/src/main/services/auth.service.ts index 202a6ff..80e9a73 100644 --- a/src/main/services/auth.service.ts +++ b/src/main/services/auth.service.ts @@ -1,5 +1,6 @@ import { AuthApi } from '../api/auth.js'; import { configStore } from '../store/config.store.js'; +import { accountService } from './account.service.js'; import { appStateService } from './app-state.service.js'; import { disableStealth } from './window-control.service.js'; @@ -99,6 +100,12 @@ export class AuthService { // update app state to logged in appStateService.updateState({ isLoggedIn: true }); + // pull this account's synced config (full name, profile, context) from the backend. + // Cleared first so a failed pull leaves the config unloaded rather than whatever + // the previously signed-in account left behind. + accountService.clearState(); + await accountService.pullFromBackend(); + return { success: true }; } catch { return { success: false, error: 'Login failed' }; @@ -125,6 +132,7 @@ export class AuthService { // clear session token and update app state configStore.updateConfig({ sessionToken: '' }); appStateService.updateState({ isLoggedIn: false }); + accountService.clearState(); // clear credentials if remember me is not checked const config = configStore.getConfig(); diff --git a/src/main/services/health-check.service.ts b/src/main/services/health-check.service.ts index 5f1515b..5119677 100644 --- a/src/main/services/health-check.service.ts +++ b/src/main/services/health-check.service.ts @@ -5,6 +5,7 @@ import { HealthCheckApi } from '../api/health-check.js'; import { safeSleep } from '../utils/sleep.js'; +import { accountService } from './account.service.js'; import { appStateService } from './app-state.service.js'; import { authService } from './auth.service.js'; import { pushNotificationService } from './push-notification.service.js'; @@ -25,10 +26,12 @@ export class HealthCheckService { this.running = true; appStateService.updateState({ isLoggedIn: null }); + let loggedIn = false; try { const res = await this.client.pingClient(); + loggedIn = res.status === 200; appStateService.updateState({ - isLoggedIn: res.status === 200, + isLoggedIn: loggedIn, credits: res.data?.credits, userRole: res.data?.user_role, providedLLMModel: res.data?.provided_llm_model, @@ -40,6 +43,14 @@ export class HealthCheckService { this.startBackendLoop(); this.startClientLoop(); + + // Remembered sessions log the user in here without going through authService.login(), + // so this is where a returning device needs to pull its synced account config. Kicked + // off after the loops and left unawaited on purpose: a slow /users/me must not keep + // backend liveness and session-expiry monitoring from ever starting. + if (loggedIn) { + void accountService.pullFromBackend(); + } } /** diff --git a/src/main/services/suggestion-action.service.ts b/src/main/services/suggestion-action.service.ts index 8846311..75ed88d 100644 --- a/src/main/services/suggestion-action.service.ts +++ b/src/main/services/suggestion-action.service.ts @@ -186,11 +186,12 @@ export class ActionSuggestionService { private async generateSuggestion(taskId: string, transcripts: Transcript[]): Promise { const timestamp = DateTimeUtil.now(); const conf = configStore.getConfig(); + const interviewConfig = appStateService.getState().interviewConfig; const payload: GenerateActionSuggestionRequest = { config: conf.llmConf, - profile_data: conf.interviewConf.profileData, - context: conf.interviewConf.jobDescription, + profile_data: interviewConfig.profileData, + context: interviewConfig.context, transcripts: transcripts, image_names: [...this.uploadedImageNames], }; diff --git a/src/main/services/suggestion-live.service.ts b/src/main/services/suggestion-live.service.ts index d21f347..9d3c403 100644 --- a/src/main/services/suggestion-live.service.ts +++ b/src/main/services/suggestion-live.service.ts @@ -53,10 +53,11 @@ class LiveSuggestionService { try { const conf = configStore.getConfig(); + const interviewConfig = appStateService.getState().interviewConfig; const requestBody: GenerateLiveSuggestionRequest = { config: conf.llmConf, - profile_data: conf.interviewConf.profileData, - context: conf.interviewConf.jobDescription, + profile_data: interviewConfig.profileData, + context: interviewConfig.context, transcripts: transcripts, }; diff --git a/src/main/services/tools.service.ts b/src/main/services/tools.service.ts index 0745c43..59f8787 100644 --- a/src/main/services/tools.service.ts +++ b/src/main/services/tools.service.ts @@ -31,7 +31,7 @@ class ToolsService { async exportTranscript(): Promise { // Prepare request data - const username = configStore.getConfig().interviewConf.username; + const username = appStateService.getState().interviewConfig.fullName; const transcripts = appStateService.getState().transcripts; const suggestions = appStateService.getState().liveSuggestions; diff --git a/src/main/store/config.store.ts b/src/main/store/config.store.ts index 0f7d3b7..bad3293 100644 --- a/src/main/store/config.store.ts +++ b/src/main/store/config.store.ts @@ -10,11 +10,6 @@ import { LLMConfig } from '../types/llm.js'; // Runtime configuration (matches Config type in frontend) export interface RuntimeConfig { - interviewConf: { - username: string; - profileData: string; - jobDescription: string; - }; language: string; sessionToken: string; rememberMe: boolean; @@ -32,11 +27,6 @@ export interface RuntimeConfig { // Default runtime configuration const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { - interviewConf: { - username: '', - profileData: '', - jobDescription: '', - }, language: 'en', sessionToken: '', rememberMe: true, @@ -52,6 +42,16 @@ const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { autoScrollTranscript: true, }; +// interviewConf (full name, profile, context) used to be cached under `runtime`, but it's now +// backend-persisted and lives only in-memory (see AccountService/AppStateService). +export interface LegacyInterviewConf { + username?: string; + profileData?: string; + jobDescription?: string; +} + +type StoredRuntime = Partial & { interviewConf?: LegacyInterviewConf }; + interface StoredConfig { window?: { bounds?: { x: number; y: number; width: number; height: number }; @@ -59,7 +59,10 @@ interface StoredConfig { zoomFactor?: number; opacityLevel?: number; }; - runtime?: Partial; + runtime?: StoredRuntime; + + /** Account id that claimed the leftover pre-sync config; see AccountService.migrateLegacyConfig. */ + legacyInterviewConfOwner?: string; } class ConfigStore { @@ -75,30 +78,54 @@ class ConfigStore { } /** - * Get runtime configuration from local store + * Get runtime configuration from local store. + * + * `interviewConf` is stripped: it is a pre-sync leftover awaiting migration, and this object + * is handed to the renderer over `config:get`. The CV reaches the renderer only through + * `account:get`. */ getConfig(): RuntimeConfig { - const config = this.store.get('runtime', DEFAULT_RUNTIME_CONFIG); - return { ...DEFAULT_RUNTIME_CONFIG, ...config } as RuntimeConfig; + const stored: StoredRuntime = { ...this.store.get('runtime', DEFAULT_RUNTIME_CONFIG) }; + delete stored.interviewConf; + return { ...DEFAULT_RUNTIME_CONFIG, ...stored } as RuntimeConfig; } /** - * Update runtime configuration in local store + * Update runtime configuration in local store. + * + * Merges onto the raw stored object rather than `getConfig()`, so a leftover `interviewConf` + * survives the write instead of being dropped before AccountService can migrate it. */ updateConfig(updates: Partial): RuntimeConfig { - const current = this.getConfig(); - const updated = { ...current, ...updates }; - - // Deep merge interview_conf if it's being partially updated - if (updates.interviewConf) { - updated.interviewConf = { - ...current.interviewConf, - ...updates.interviewConf, - }; - } + const stored = this.getStoredRuntime() ?? {}; + this.store.set('runtime', { ...DEFAULT_RUNTIME_CONFIG, ...stored, ...updates }); + return this.getConfig(); + } + + /** + * Raw stored runtime object, without default backfill. + * + * Needed by migrations that must distinguish "absent on disk" from "set to the default", + * and to reach keys that are no longer part of `RuntimeConfig`. + */ + getStoredRuntime(): StoredRuntime | undefined { + return this.store.get('runtime'); + } + + setStoredRuntime(runtime: StoredRuntime): void { + this.store.set('runtime', runtime); + } + + getLegacyInterviewConfOwner(): string | undefined { + return this.store.get('legacyInterviewConfOwner'); + } - this.store.set('runtime', updated); - return updated; + setLegacyInterviewConfOwner(accountId: string): void { + this.store.set('legacyInterviewConfOwner', accountId); + } + + clearLegacyInterviewConfOwner(): void { + this.store.delete('legacyInterviewConfOwner'); } /** @@ -183,8 +210,7 @@ export const configStore = new ConfigStore(); // restart, which is why autoScroll was bouncing back to true. (() => { // read the raw stored object so we can test for undefined values - // eslint-disable-next-line - const raw = (configStore as any).store.get('runtime') as Partial | undefined; + const raw = configStore.getStoredRuntime(); const migration: Partial = {}; if (raw?.autoScrollLiveSuggestions === undefined) { migration.autoScrollLiveSuggestions = true; @@ -200,3 +226,42 @@ export const configStore = new ConfigStore(); configStore.updateConfig(migration); } })(); // migration block + +// Read (but do not yet delete) any leftover local copy, so AccountService can migrate it +// onto the account. Deleting here unconditionally would destroy the only copy whenever the +// first launch after upgrade happens to be offline. +let legacyInterviewConf: LegacyInterviewConf | null = + configStore.getStoredRuntime()?.interviewConf ?? null; + +export function getLegacyInterviewConf(): LegacyInterviewConf | null { + return legacyInterviewConf; +} + +/** + * Account id that claimed the leftover pre-sync config. + * + * Persisted rather than held in memory: a failed migration deliberately keeps the local copy + * for retry, and an in-process-only claim is lost on restart. Without this, a second user + * signing in first after that restart would inherit the first user's CV. + */ +export function getLegacyInterviewConfOwner(): string | null { + return configStore.getLegacyInterviewConfOwner() ?? null; +} + +export function claimLegacyInterviewConf(accountId: string): void { + configStore.setLegacyInterviewConfOwner(accountId); +} + +// Called once the account is known to hold the config, so it stops lingering on disk. +// The in-memory copy goes too: it outlives sign-out, so leaving it would let a later +// sign-in on the same process migrate one user's CV onto a different account. +export function clearLegacyInterviewConf(): void { + legacyInterviewConf = null; + configStore.clearLegacyInterviewConfOwner(); + + const raw = configStore.getStoredRuntime(); + if (raw && 'interviewConf' in raw) { + delete raw.interviewConf; + configStore.setStoredRuntime(raw); + } +} diff --git a/src/main/types/account.ts b/src/main/types/account.ts new file mode 100644 index 0000000..fa2abae --- /dev/null +++ b/src/main/types/account.ts @@ -0,0 +1,27 @@ +/** + * Account Types + */ + +export interface InterviewConfig { + full_name: string; + profile_data: string; + context: string; +} + +export interface UserAccount { + _id: string; + username: string; + email: string; + role: string; + status: string; + credits: number; + interview_config: InterviewConfig | null; + created_at: number; + updated_at: number | null; +} + +export interface UpdateInterviewConfigRequest { + full_name: string; + profile_data: string; + context: string; +} diff --git a/src/main/types/app-state.ts b/src/main/types/app-state.ts index 52b39cb..27ca173 100644 --- a/src/main/types/app-state.ts +++ b/src/main/types/app-state.ts @@ -51,6 +51,24 @@ export interface ActionSuggestion { error: string; } +export interface InterviewConfig { + fullName: string; + profileData: string; + context: string; +} + +/** + * What the renderer is told about the interview config. + * + * The profile and context hold a full CV and job description (up to 128k chars each) and the + * whole app state is broadcast on every change, so the bulk stays in the main process. The + * configuration dialog fetches the full values on demand over `account:get`. + */ +export interface InterviewConfigSummary { + fullName: string; + hasProfileData: boolean; +} + export interface AppState { isStealth: boolean; isBackendLive: boolean | null; @@ -62,4 +80,12 @@ export interface AppState { credits?: number; userRole?: UserRole; providedLLMModel?: string; + interviewConfig: InterviewConfig; + /** False until the account's config has been read this session; editing is unsafe before then. */ + interviewConfigLoaded: boolean; } + +/** The app state as sent to the renderer, with the interview config reduced to a summary. */ +export type RendererAppState = Omit & { + interviewConfig: InterviewConfigSummary; +}; diff --git a/src/renderer/components/custom/configuration-dialog.tsx b/src/renderer/components/custom/configuration-dialog.tsx index 00020f5..eb009e0 100644 --- a/src/renderer/components/custom/configuration-dialog.tsx +++ b/src/renderer/components/custom/configuration-dialog.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; -import { useConfigStore } from '@/hooks/use-config-store'; +import { getElectron } from '@/lib/utils'; import { Dialog, @@ -14,43 +15,78 @@ import { DialogTitle, } from '../ui/dialog'; +// Kept in sync with the backend's MAX_PROFILE_DATA_LENGTH / MAX_CONTEXT_LENGTH (app/cfg/llm.py) +const MAX_FIELD_LENGTH = 128_000; + interface ConfigurationDialogProps { isOpen: boolean; onOpenChange: (open: boolean) => void; } export default function ConfigurationDialog({ isOpen, onOpenChange }: ConfigurationDialogProps) { - const { config, updateConfig } = useConfigStore(); - const [name, setName] = useState(''); const [profileData, setProfileData] = useState(''); - const [jobDescription, setJobDescription] = useState(''); + const [context, setContext] = useState(''); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(false); + const [configLoaded, setConfigLoaded] = useState(false); - // Initialize form values when dialog opens - runs before paint + // Load once per open, straight from main, rather than tracking app state. A save replaces + // the whole config, so this both refreshes what another device may have changed and keeps + // a late-arriving update from overwriting what the user is currently typing. useEffect(() => { if (!isOpen) return; - // Queue state updates in a single microtask to avoid cascading renders - Promise.resolve().then(() => { - if (config?.interviewConf) { - setName(config.interviewConf.username ?? ''); - setProfileData(config.interviewConf.profileData ?? ''); - setJobDescription(config.interviewConf.jobDescription ?? ''); + let cancelled = false; + setLoading(true); + setConfigLoaded(false); + + void (async () => { + try { + const result = await getElectron()?.account?.get(); + if (cancelled) return; + + // Show whatever main has even when the refresh failed, so the user is not staring at a + // blank form - but only mark it loaded (and thus safe to save over) when it succeeded. + if (result?.data) { + setName(result.data.fullName); + setProfileData(result.data.profileData); + setContext(result.data.context); + } + setConfigLoaded(result?.success ?? false); + } catch (error) { + console.error('Failed to load configuration:', error); + if (!cancelled) setConfigLoaded(false); + } finally { + if (!cancelled) setLoading(false); } - }); - }, [isOpen, config?.interviewConf]); + })(); - const handleSave = async () => { - const interviewConf = { - username: name, - profileData: profileData, - jobDescription: jobDescription, + return () => { + cancelled = true; }; + }, [isOpen]); + + const handleSave = async () => { + setSaving(true); try { - await updateConfig({ interviewConf: interviewConf }); + const electron = getElectron(); + if (!electron?.account) { + throw new Error('Electron API not available'); + } + + const result = await electron.account.update(name, profileData, context); + if (!result.success) { + throw new Error(result.error || 'Failed to save configuration'); + } + + // Account update pushes a fresh app-state broadcast, so no manual refresh needed here onOpenChange(false); } catch (error) { console.error('Failed to save configuration:', error); + toast.error(error instanceof Error ? error.message : 'Failed to save configuration'); + } finally { + setSaving(false); } }; @@ -89,7 +125,7 @@ export default function ConfigurationDialog({ isOpen, onOpenChange }: Configurat onChange={(e) => setProfileData(e.target.value)} placeholder="Enter your profile information. (e.g. your CV/resume, LinkedIn profile, or a brief bio)" className="text-sm min-h-20 max-h-40 overflow-auto" - maxLength={60000} + maxLength={MAX_FIELD_LENGTH} /> @@ -98,11 +134,11 @@ export default function ConfigurationDialog({ isOpen, onOpenChange }: Configurat Context (Recommended)