Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3bbcf4d
feat(language): make the stubbed Language enum real
alpha5611331 Aug 24, 2026
d35527c
feat(suggestions): send the interview language on every request
alpha5611331 Aug 24, 2026
a52f210
feat(asr): open the transcription sockets on the session's language, …
alpha5611331 Aug 24, 2026
508d547
feat(ui): interview language picker on the control bar
alpha5611331 Aug 24, 2026
8ebcdc0
test(language): pin the default, the set, and the unknown-code fallback
alpha5611331 Aug 24, 2026
e9c7286
docs: record the two-speed language switch and the sockets it reconnects
alpha5611331 Aug 24, 2026
8c9b983
fix(asr): report the orphaned utterance on a language switch, and rec…
alpha5611331 Aug 24, 2026
2dfb35d
fix(audio): pick the default microphone in an effect, not during render
alpha5611331 Aug 24, 2026
adff2f8
fix(audio): stop reporting a working microphone as missing
alpha5611331 Aug 24, 2026
76fc2a8
fix(assistant): always land on a terminal running state when stopping
alpha5611331 Aug 24, 2026
b29ef7e
perf(main): stop remeasuring the panel layout on every streamed chunk
alpha5611331 Aug 24, 2026
2b4cce8
fix(suggestions): keep truncateMiddle inside the length it is given
alpha5611331 Aug 24, 2026
f358f41
fix(a11y): name every form field and announce every form error
alpha5611331 Aug 24, 2026
cc5d3a3
feat(config): say when a long field is about to truncate a paste
alpha5611331 Aug 24, 2026
eb2257d
docs: record the assistant lifecycle and device-readiness invariants
alpha5611331 Aug 24, 2026
02e478c
fix(export): refuse to summarize an interview that has not happened
alpha5611331 Aug 24, 2026
e7dfc94
fix(a11y): name the remaining icon-only controls
alpha5611331 Aug 24, 2026
54cdf2c
fix(health-check): back off instead of polling a down backend at 1 Hz
alpha5611331 Aug 24, 2026
db68743
fix(security): keep the app's window on the app, and links out of it
alpha5611331 Aug 24, 2026
838c36a
fix(security): make the navigation guard idempotent
alpha5611331 Aug 24, 2026
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
74 changes: 74 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,80 @@ The backend prompts now ask for inline emphasis on the words an answer turns on,

Each `LiveSuggestion` still carries the `mode` it was *generated* under, and the panel keys off that rather than the current setting, so toggling mid-interview leaves cards already on screen alone. What the mode selects is the presentation around the Markdown: professional promotes the headline line, normal keeps the 🪄 marker in a column of its own - prepending it to the content instead would swallow whatever structure the answer opens with.

### Assistant lifecycle

`RunningState` is what every control on the bar is gated on, and `Starting` and `Stopping` disable
all of them - Stop included. So the one invariant `useAssistantService` has to hold is that the
state always lands back on a terminal value, whatever went wrong on the way. `stopAssistant`
returns to `Idle` in a `finally`, and tears the four services down through `Promise.allSettled`
rather than `Promise.all`: `all` rejects on the first one that throws and abandons the other three,
so a single failing teardown used to leave the rest running *and* strand the app in `Stopping`
with no reachable control - unrecoverable without restarting the app, mid-interview. A partial
failure is now a toast rather than a throw, because there is nothing left for a caller to do about
it and the session is over either way.

The failed-start path is the mirror of that, and it belongs in exactly one place. `startAssistant`
already tears both services down and returns to `Idle` in its own `catch`, so `doStart` in
[control-panel/index.tsx](src/renderer/components/custom/control-panel/index.tsx) reports the error
and stops there. Calling `stopAssistant()` after it, as it used to, walked the button through a
three-second `Stopping` for a session that never started, and that call's own failure landed
outside the `try` as an unhandled rejection.

`useMediaDevices` reports `ready` alongside the device list because an empty list means two
different things - `enumerateDevices()` has not answered yet, and this machine has none - and the
control panel renders a destructive badge and refuses Start on the second. Reading them as one
put a red `!` on a working microphone for the first frames after every launch, and refused a Start
pressed quickly with a message naming a device that was there all along. An unset
`audioInputDeviceName` is a third state again, and also not "missing": `AudioGroup` is choosing
the default at that moment, in an effect - never in the render body, where the store write
re-enters React mid-commit and a failed IPC call rolls the value back into the same condition that
triggered it, one write per frame.

### Interview language

One setting decides three things: which AssemblyAI speech model transcribes the call, what language suggestions come back in, and the language of the exported report. `Language` is mirrored across the processes the way `SuggestionMode` is - [src/main/types/language.ts](src/main/types/language.ts) for the request bodies, [src/renderer/types/language.ts](src/renderer/types/language.ts) for the same enum plus the display metadata the picker needs. Six languages, because that is what `universal-streaming-multilingual` transcribes: offering one the ASR cannot hear would not degrade, it would answer a question that was never asked.

**English is the absence of the feature.** `buildStreamingUrl` sends no `language` parameter at all for English rather than `language=en`, and the backend defaults the request field, so a session that never touches the picker produces exactly the traffic it produced before this existed.

`configStore.getConfig()` resolves the language on the way *out*, not on the way in. The disk holds whatever some build wrote - a code a later release dropped, or one an older release never knew - and every consumer reads through `getConfig`, so that is the single place an unknown code can be stopped before it reaches the ASR URL and three request bodies. `test/language.test.mjs` pins it.

**The picker stays live mid-interview**, unlike Audio and Model, because an interview that switches language is the case it exists for and not one the candidate can prepare for by restarting. The two halves of the setting move at different speeds and `useInterviewLanguage` is where that is reconciled. Suggestions need nothing: every request reads the config store as it is built, so the next one already follows. The ASR carries its language as a *connection* parameter, so `liveTranscriptionService.setLanguage()` tears both sockets down and re-opens them - a second or two of gap, and whatever utterance was mid-flight is orphaned, which is why the button shows a spinner rather than pretending the change was instant and why the menu says so before the user commits.

Two guards in `AudioWsStream` make that safe, and both protect against the same failure - two sockets on one channel, one of them orphaned and still relaying audio into a dead session. `ws.onclose` ignores a close from a socket that is no longer `this.ws`, since that is the tail of a replacement rather than a disconnect; and the `switching` flag suppresses the ordinary backoff reconnect for the close `setLanguage` causes itself, which it then handles immediately instead of after `WS_RETRY_BASE_DELAY_MS`. `connectWebSocket` rebuilds the URL per attempt rather than capturing it, which is what lets a reconnect pick up the new language at all.

Two consequences of that first guard. `setLanguage` has to report `channelDisconnected` itself rather than leaving it to `onclose`: `new WebSocket` assigns `this.ws` synchronously, so the old socket's close event always arrives after the replacement exists and is correctly ignored. And `setLanguage` keys its own no-op check on `this.ws` rather than on `active`, which `start()` only sets *after* its first connect returns - in that window a socket exists on the old language and an `active` check would skip it.

The setting is persisted *before* the reconnect and never rolled back on failure: a failed reconnect that reverted the setting would leave the user with no route to the language they picked, whereas leaving it set means stopping and starting the assistant recovers.

The trigger shows the code (`EN`, `ES`) next to the icon for the same reason the tooltip names the language - the one question this control has to answer at a glance is what it is currently set to.

The app's own chrome is **not** localised, deliberately: an English button on a Spanish interview is an inconvenience, an English transcript of Spanish speech is a wrong answer read out loud.

### Navigation and external links

The panels render Markdown that came from a language model, and `remark-gfm` autolinks bare URLs,
so an anchor in this app is not necessarily one a person wrote. `installNavigationGuard()`
([src/main/navigation-guard.ts](src/main/navigation-guard.ts)) is installed before the window's
first load and closes the two routes that follow from that, neither of which announced itself.

`setWindowOpenHandler` denies **every** new window. A `target="_blank"` anchor - which is what
`SafeMarkdown` renders - asks Electron for one, and with no handler installed the default is to
make it: a chromeless BrowserWindow with no address bar showing a page the user did not choose.
A web URL is handed to the real browser instead, through `setImmediate` as Electron's own
guidance requires.

`will-navigate` pins the window to the app's own document. An anchor without a target navigates
the frame it is in, and that frame is the app - preload runs on whatever document loads next, so
a remote page would inherit `window.electronAPI`, and with it the session token through
`config.get()` and the candidate's CV through `account.get()`. `file:` origins serialize to
`"null"`, so the packaged build is matched on its exact document URL rather than on an origin
comparison that could never hold.

Both routes and the `external:open` IPC handler go through the same `openExternally()`, which
allows `http:`, `https:` and `mailto:` only. `shell.openExternal` delegates to the OS protocol
handler, so `file:` launches whatever the path points at and a registered custom scheme runs
whatever claimed it. `test/navigation-guard.test.mjs` pins all three.

### Routing

Hash-based router (required for Electron `file://` protocol). Routes: `/` (index, redirects based on login state) -> `/auth/login`, `/auth/signup`, or `/auth/forgot-password` -> `/main` (interview UI) -> `/payment`.
Expand Down
11 changes: 9 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { app, BrowserWindow, Menu } from 'electron';
import path from 'path';
import { fileURLToPath } from 'url';
import { fileURLToPath, pathToFileURL } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Expand All @@ -22,6 +22,7 @@ import { registerLiveSuggestionHandlers } from './ipc/suggestion-live.js';
import { registerToolsHandlers } from './ipc/tools.js';
import { initializeAudioLoopback, registerTranscriptHandlers } from './ipc/transcript.js';
import { registerWindowHandlers } from './ipc/window.js';
import { installNavigationGuard } from './navigation-guard.js';
import { autoUpdaterService } from './services/auto-updater.service.js';
import { healthCheckService } from './services/health-check.service.js';
import { transcriptService } from './services/transcript.service.js';
Expand Down Expand Up @@ -172,14 +173,20 @@ async function createWindow() {
// Clear cache before loading
await win.webContents.session.clearCache();

// Installed before the load, so the guard is in place for the app's very first document.
// Idempotent, because this function runs again when the single-instance lock recovers a
// destroyed window and `web-contents-created` is an app-level event.
if (EnvUtil.isDev()) {
win.loadURL('http://localhost:15173');
const devUrl = 'http://localhost:15173';
installNavigationGuard(devUrl);
win.loadURL(devUrl);
win.webContents.openDevTools();
} else {
// Use app.getAppPath() for conventional path resolution
// This works correctly whether the app is packaged or not
const distPath = path.join(app.getAppPath(), 'dist', 'index.html');
console.log('Loading from:', distPath);
installNavigationGuard(pathToFileURL(distPath).href);
win.loadFile(distPath);
}
}
Expand Down
16 changes: 6 additions & 10 deletions src/main/ipc/external.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import { ipcMain, shell } from 'electron';

import { openExternally } from '../navigation-guard.js';

export function registerExternalHandlers(): void {
ipcMain.handle('external:open', async (_event, url: string) => {
try {
if (!url || typeof url !== 'string') return { success: false, error: 'invalid-url' };
await shell.openExternal(url);
return { success: true };
} catch (err: unknown) {
console.warn('[ExternalHandlers] external:open error:', err);
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
});
// Shared with the window-open handler rather than calling shell.openExternal directly, so a
// link takes the same route and the same scheme check whichever way it arrives. openExternal
// hands the URL to the OS protocol handler, so `file:` launches what the path points at.
ipcMain.handle('external:open', async (_event, url: string) => openExternally(url));

ipcMain.handle('external:open-file', async (_event, filePath: string) => {
const err = await shell.openPath(filePath);
Expand Down
109 changes: 109 additions & 0 deletions src/main/navigation-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { app, shell } from 'electron';

/**
* Schemes `shell.openExternal` is allowed to hand to the operating system.
*
* openExternal delegates to the OS protocol handler, so `file:` launches whatever the path points
* at and a registered custom scheme runs whatever claimed it. Only the three that mean "show this
* to the user in their own application" are permitted.
*/
const OPENABLE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);

export function isOpenableExternally(url: string): boolean {
try {
return OPENABLE_PROTOCOLS.has(new URL(url).protocol);
} catch {
return false;
}
}

/**
* Open a URL in the user's own browser, or refuse it.
*
* Shared by the `external:open` IPC handler and the window-open handler below, so a link takes
* the same route whether the renderer asked for it explicitly or a `target="_blank"` anchor did.
*/
export async function openExternally(url: string): Promise<{ success: boolean; error?: string }> {
if (!url || typeof url !== 'string') return { success: false, error: 'invalid-url' };
if (!isOpenableExternally(url)) {
console.warn('[NavigationGuard] Refused to open a non-web URL:', url);
return { success: false, error: 'unsupported-scheme' };
}

try {
await shell.openExternal(url);
return { success: true };
} catch (err: unknown) {
console.warn('[NavigationGuard] openExternal error:', err);
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
}

/**
* Keep the app's own web contents on the app.
*
* The panels render Markdown that came from a language model, and `remark-gfm` autolinks bare
* URLs, so an anchor in this app is not necessarily one anybody wrote. Two things follow from
* that, and neither was covered before.
*
* A `target="_blank"` anchor asks Electron for a new window, and with no handler installed the
* default is to make one: a chromeless BrowserWindow, no address bar, showing a page the user did
* not choose. Every one of those is denied and handed to the real browser instead, which is both
* safer and what the user expected from a link.
*
* An anchor without a target navigates the frame it is in, and that frame is the app - carrying
* the preload bridge with it, since preload runs on whatever document loads next. A remote page
* inheriting `window.electronAPI` would have the session token through `config.get()` and the
* candidate's CV through `account.get()`. `will-navigate` pins the window to the app's own
* document; the dev server and the packaged `file://` bundle are the only origins it may hold.
*/
let installed = false;

export function installNavigationGuard(appUrl: string): void {
// `createWindow()` runs again when the single-instance lock recovers a destroyed window, and
// `web-contents-created` is an app-level event: without this the second call would stack a
// duplicate will-navigate listener on every web contents for the rest of the process.
if (installed) return;
installed = true;

let appOrigin: string;
try {
appOrigin = new URL(appUrl).origin;
} catch {
appOrigin = '';
}

app.on('web-contents-created', (_event, contents) => {
contents.setWindowOpenHandler(({ url }) => {
// setImmediate, per Electron's own guidance: openExternal must not run inside the handler.
if (isOpenableExternally(url)) {
setImmediate(() => void openExternally(url));
} else {
console.warn('[NavigationGuard] Blocked a window for:', url);
}
return { action: 'deny' };
});

contents.on('will-navigate', (event, navigationUrl) => {
let target: URL;
try {
target = new URL(navigationUrl);
} catch {
event.preventDefault();
return;
}

// `file:` origins serialize to "null", so the packaged build is matched on the document it
// is already showing rather than on an origin comparison that can never hold.
const sameDocument =
target.href === appUrl || (appOrigin !== '' && target.origin === appOrigin);
if (sameDocument) return;

event.preventDefault();
console.warn('[NavigationGuard] Blocked navigation to:', navigationUrl);
if (isOpenableExternally(navigationUrl)) {
setImmediate(() => void openExternally(navigationUrl));
}
});
});
}
34 changes: 30 additions & 4 deletions src/main/services/health-check.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ import { pushNotificationService } from './push-notification.service.js';
const SUCCESS_INTERVAL = 5 * 1000; // 5 seconds
const FAILURE_INTERVAL = 1 * 1000; // 1 second

// A backend that is down is usually down for longer than a second, and the first retry is the
// only one that benefits from being immediate. Without a ceiling the loop below polls at 1 Hz
// for as long as the app is open - a laptop left overnight on a dropped connection makes tens of
// thousands of failing requests, and every installed client comes back at the same rate the
// moment a real outage ends. Backoff is capped rather than unbounded so recovery is still
// noticed within half a minute, which is what the reconnect notice in the UI is waiting on.
const MAX_FAILURE_INTERVAL = 30 * 1000;
const FAILURE_BACKOFF_FACTOR = 2;

function nextFailureInterval(current: number): number {
return Math.min(current * FAILURE_BACKOFF_FACTOR, MAX_FAILURE_INTERVAL);
}

export class HealthCheckService {
private running = false;
private client = new HealthCheckApi();
Expand Down Expand Up @@ -64,6 +77,8 @@ export class HealthCheckService {
/** Backend ping loop */
private startBackendLoop(): void {
(async () => {
let failureInterval = FAILURE_INTERVAL;

while (this.running) {
let backendLive = false;
try {
Expand All @@ -74,13 +89,17 @@ export class HealthCheckService {
}

if (!backendLive) {
console.log('[HealthCheckService] Backend not live');
console.log(`[HealthCheckService] Backend not live, next check in ${failureInterval}ms`);
}

// Update app state
appStateService.updateState({ isBackendLive: backendLive });

const next = backendLive ? SUCCESS_INTERVAL : FAILURE_INTERVAL;
// Reset on the way back up, so one blip does not leave the app checking slowly for the
// rest of the session.
const next = backendLive ? SUCCESS_INTERVAL : failureInterval;
failureInterval = backendLive ? FAILURE_INTERVAL : nextFailureInterval(failureInterval);

await safeSleep(next);
}
})();
Expand All @@ -89,12 +108,17 @@ export class HealthCheckService {
/** Client ping loop */
private startClientLoop(): void {
(async () => {
let failureInterval = FAILURE_INTERVAL;

while (this.running) {
const state = appStateService.getState();

// skip if not logged in
// skip if not logged in. Kept at FAILURE_INTERVAL: it makes no request, so it costs a
// timer wake-up rather than traffic, and it is what decides how soon after a sign-in the
// credits and role reach the UI.
if (!state.isLoggedIn) {
await safeSleep(FAILURE_INTERVAL);
failureInterval = FAILURE_INTERVAL;
continue;
}

Expand All @@ -117,9 +141,11 @@ export class HealthCheckService {
userRole: res.data?.user_role,
});
}
failureInterval = FAILURE_INTERVAL;
} catch (error) {
console.error('[HealthCheckService] Client ping error:', error);
nextInterval = FAILURE_INTERVAL;
nextInterval = failureInterval;
failureInterval = nextFailureInterval(failureInterval);
}

await safeSleep(nextInterval);
Expand Down
1 change: 1 addition & 0 deletions src/main/services/suggestion-action.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ export class ActionSuggestionService {
transcripts: transcripts.slice(-TRANSCRIPT_UPLOAD_LIMIT),
image_names: [...this.uploadedImageNames],
mode: conf.professionalMode ? SuggestionMode.Professional : SuggestionMode.Normal,
language: conf.language,
};

const lastQuestion = this.getLastInterviewerQuestion(transcripts);
Expand Down
1 change: 1 addition & 0 deletions src/main/services/suggestion-live.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ class LiveSuggestionService {
transcripts: transcripts.slice(-TRANSCRIPT_UPLOAD_LIMIT),
mode,
turn_verdict: turnVerdict,
language: conf.language,
};

armStallTimer(LIVE_SUGGESTION_TTFB_MS);
Expand Down
Loading