Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
34 changes: 27 additions & 7 deletions src/main/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,13 @@ export class ApiClient {
delete this.headers['Authorization'];
}

async get<T>(path: string, params?: Record<string, unknown>): Promise<ApiResponse<T>> {
async get<T>(
path: string,
params?: Record<string, unknown>,
timeoutMs?: number
): Promise<ApiResponse<T>> {
const url = this.buildUrl(path, params);
return this.request<T>('GET', url);
return this.request<T>('GET', url, undefined, timeoutMs);
}

async postFormData<T>(path: string, formData: FormData): Promise<ApiResponse<T>> {
Expand Down Expand Up @@ -103,9 +107,9 @@ export class ApiClient {
}
}

async post<T>(path: string, body?: unknown): Promise<ApiResponse<T>> {
async post<T>(path: string, body?: unknown, timeoutMs?: number): Promise<ApiResponse<T>> {
const url = this.buildUrl(path);
return this.request<T>('POST', url, body);
return this.request<T>('POST', url, body, timeoutMs);
}

async postStream(path: string, body?: unknown): Promise<ReadableStream<Uint8Array> | null> {
Expand All @@ -118,12 +122,22 @@ export class ApiClient {
return this.request<T>('PUT', url, body);
}

async patch<T>(path: string, body?: unknown, timeoutMs?: number): Promise<ApiResponse<T>> {
const url = this.buildUrl(path);
return this.request<T>('PATCH', url, body, timeoutMs);
}

async delete<T>(path: string): Promise<ApiResponse<T>> {
const url = this.buildUrl(path);
return this.request<T>('DELETE', url);
}

private async request<T>(method: string, url: string, body?: unknown): Promise<ApiResponse<T>> {
private async request<T>(
method: string,
url: string,
body?: unknown,
timeoutMs?: number
): Promise<ApiResponse<T>> {
try {
const sessionToken = configStore.getConfig().sessionToken;
if (sessionToken) {
Expand All @@ -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(() => ({}));
Expand All @@ -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',
},
};
}
Expand Down
21 changes: 17 additions & 4 deletions src/main/api/health-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,34 @@ 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<ApiResponse<string>> {
return this.get('/api/health-check/ping');
return this.get('/api/health-check/ping', undefined, PING_TIMEOUT_MS);
}

/**
* Ping client to backend with device info
*/
async pingClient(): Promise<ApiResponse<ClientPingResponse>> {
const appState = appStateService.getState();
return this.post<ClientPingResponse>('/api/health-check/ping-client', {
is_assistant_running: appState.runningState === RunningState.Running,
} as ClientPingRequest);
return this.post<ClientPingResponse>(
'/api/health-check/ping-client',
{
is_assistant_running: appState.runningState === RunningState.Running,
} as ClientPingRequest,
PING_TIMEOUT_MS
);
}
}
28 changes: 28 additions & 0 deletions src/main/api/users.ts
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<UserAccount>> {
return this.get<UserAccount>('/api/users/me', undefined, REQUEST_TIMEOUT_MS);
}

/**
* Replace the authenticated user's interview configuration
*/
async updateInterviewConfig(data: UpdateInterviewConfigRequest): Promise<ApiResponse<UserAccount>> {
return this.patch<UserAccount>('/api/users/me/interview-config', data, REQUEST_TIMEOUT_MS);
}
}
2 changes: 2 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -182,6 +183,7 @@ app.whenReady().then(async () => {
registerConfigHandlers();
registerAppStateHandlers();
registerAuthHandlers();
registerAccountHandlers();
registerPaymentHandlers();
registerLLMHandlers();
registerPermissionHandlers();
Expand Down
24 changes: 24 additions & 0 deletions src/main/ipc/account.ts
Original file line number Diff line number Diff line change
@@ -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();
});
}
4 changes: 2 additions & 2 deletions src/main/ipc/app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
}
7 changes: 7 additions & 0 deletions src/main/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading