Skip to content
Merged
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
15 changes: 9 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,16 @@ When adding new code:

## Test-First Requirements

Before implementing behavior changes:
Always define expected behavior and acceptance criteria before implementing. Treat "what behavior changed?" as a required design question before coding.

- Add unit tests for pure logic.
- Add integration tests for filesystem, IPC, or service coordination.
- Add or extend e2e coverage for critical user flows if the change affects runtime behavior.
- For every user-visible behavior change, add at least one proving test that would fail without the change.
- Treat "what behavior changed?" as a required design question before coding.
How to prove the behavior depends on the layer — apply tests-first where it earns its keep, and be honest where it cannot:

- Pure logic and main-process services (`src/shared/**`, `src/main/services/**`): write the proving unit/integration tests first. These paths test cheaply against temp directories and fakes, and this is where tests-first pays off most.
- Filesystem, IPC, or service coordination: add integration tests alongside the change.
- OS-, device-, or `MediaRecorder`-level behavior (real disk-full, device disconnect, app quit ordering, capture edge cases): unit tests with fakes are NOT sufficient proof. Add e2e/smoke coverage where feasible, and document explicit manual verification steps for what automation cannot reach. Never claim durability or capture reliability is proven from helper-only tests.
- Renderer UI glue (banners, overlays, status text): prefer behavior-level e2e/smoke checks or documented manual verification over brittle DOM-assertion unit tests written first. Extract decision logic into a pure helper and unit test that instead.

For every user-visible behavior change, add at least one proving test that would fail without the change — at the most honest layer available, not merely the most convenient one.

Minimum expectation by change type:

Expand Down
16 changes: 16 additions & 0 deletions docs/production/feature-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,22 @@ Acceptance criteria:
- Recovered take is not duplicated if already present.
- Recovery file is cleared on successful append completion.

### D2. Quit guard while recording

- Closing the window mid-recording is intercepted; the renderer prompts
"Recording in progress — stop and save before quitting?".
- On confirm, the recording is stopped and finalized to disk before the
window actually closes; on cancel, recording continues.

Acceptance criteria:

- Window close is prevented only while a recording is active (renderer keeps
main informed via `recording:set-active`).
- A stop/finalize failure still allows the close (bytes remain in `.part`
files and are recoverable on next launch).
- If the renderer is unreachable, the close proceeds instead of wedging the
window open.

## E. Timeline Editor

### E1. Enter/exit timeline
Expand Down
21 changes: 21 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
systemPreferences
} from 'electron';

import { createCloseGuard } from './main/app/close-guard';
import { createWindow } from './main/app/create-window';
import {
registerDisplayMediaHandler,
Expand All @@ -30,6 +31,10 @@ let win: BrowserWindow | null = null;

const projectService = createProjectService({ app });

// Quit guard: prevents closing the window mid-recording until the renderer
// stops/finalizes (or the user confirms the close anyway).
const closeGuard = createCloseGuard();

registerIpcHandlers({
ipcMain,
app,
Expand All @@ -38,6 +43,7 @@ registerIpcHandlers({
shell,
systemPreferences,
getWindow: () => win,
closeGuard,
projectService,
renderComposite,
exportPremiereProject,
Expand All @@ -52,12 +58,16 @@ registerIpcHandlers({
function createMainWindow(): void {
win = createWindow({
BrowserWindow,
closeGuard,
onConsoleMessage: ({ level, message, line, sourceId }) => {
console.log(`[renderer:${level}] ${message} (${sourceId}:${line})`);
}
});
win.on('closed', () => {
win = null;
// The renderer that owned the recording flag is gone; reset so a window
// reopened via app 'activate' does not inherit a stale guard state.
closeGuard.setRecordingActive(false);
});
}

Expand All @@ -69,6 +79,17 @@ app.whenReady().then(() => {
createMainWindow();
});

app.on('before-quit', () => {
// Shutdown safety: flush and close any recording fds still open so their
// .part files survive intact on disk for orphan recovery on next launch.
// Best-effort and synchronous; never throws (and must never block quit).
try {
recordingService.closeAllRecordingHandlesForShutdown();
} catch (error) {
console.warn('[recording] shutdown flush failed:', error);
}
});

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
Expand Down
53 changes: 53 additions & 0 deletions src/main/app/close-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Close guard: pure decision state for "can the window close right now?".
*
* The renderer keeps main informed via the fire-and-forget
* `recording:set-active` IPC channel whenever recording starts/stops, so the
* BrowserWindow 'close' handler can decide synchronously (no renderer
* round-trip) whether to intercept the close.
*
* When a close is intercepted, the renderer is asked (via
* `app:close-requested`) to stop/finalize the recording and then confirm the
* real close with `app:confirm-close`, which arms a one-shot bypass before
* `win.close()` re-fires the 'close' event.
*/

export type CloseRequestDecision = 'allow' | 'prevent';

export interface CloseGuard {
/** Renderer-driven recording state; coerced to a strict boolean. */
setRecordingActive(active: boolean): void;
isRecordingActive(): boolean;
/**
* Arm a one-shot bypass so the next close request is allowed even if the
* recording flag is still set (e.g. stopRecording threw — the bytes are in
* the .part file and recoverable, so the user must never be trapped).
*/
confirmClose(): void;
/** Decide the fate of a window 'close' event. Consumes the bypass. */
handleCloseRequest(): CloseRequestDecision;
}

export function createCloseGuard(): CloseGuard {
let recordingActive = false;
let closeConfirmed = false;

return {
setRecordingActive(active: boolean): void {
recordingActive = Boolean(active);
},
isRecordingActive(): boolean {
return recordingActive;
},
confirmClose(): void {
closeConfirmed = true;
},
handleCloseRequest(): CloseRequestDecision {
if (closeConfirmed) {
closeConfirmed = false;
return 'allow';
}
return recordingActive ? 'prevent' : 'allow';
}
};
}
37 changes: 36 additions & 1 deletion src/main/app/create-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ import type {
Event
} from 'electron';

import type { CloseGuard } from './close-guard';

/**
* Sent to the renderer when a window close was intercepted because a
* recording is active. The renderer prompts, stops/finalizes, and then calls
* `app:confirm-close` to trigger the real close.
*/
export const CLOSE_REQUESTED_CHANNEL = 'app:close-requested';

interface ConsoleMessagePayload {
event: Event;
level: number;
Expand All @@ -21,11 +30,13 @@ export type BrowserWindowConstructor = new (
export function createWindow({
BrowserWindow,
onConsoleMessage,
appRootDir = path.join(__dirname, '..', '..')
appRootDir = path.join(__dirname, '..', '..'),
closeGuard
}: {
BrowserWindow: BrowserWindowConstructor;
onConsoleMessage?: (payload: ConsoleMessagePayload) => void;
appRootDir?: string;
closeGuard?: CloseGuard;
}): ElectronBrowserWindow {
const win = new BrowserWindow({
width: 960,
Expand Down Expand Up @@ -72,6 +83,30 @@ export function createWindow({
}
});

// Quit guard: while a recording is active (renderer keeps the guard
// informed via 'recording:set-active'), intercept the close and ask the
// renderer to stop + finalize first. The renderer confirms the real close
// via 'app:confirm-close', which arms a one-shot bypass on the guard.
if (closeGuard) {
win.on('close', (event) => {
if (closeGuard.handleCloseRequest() !== 'prevent') return;

// If the renderer is already gone there is nobody left to finalize and
// confirm; the streamed bytes are safe in the .part files (recovered on
// next launch), so let the close proceed instead of wedging the window.
if (win.webContents.isDestroyed()) return;

event.preventDefault();
try {
win.webContents.send(CLOSE_REQUESTED_CHANNEL);
} catch (error) {
console.warn('close-requested notification failed; closing anyway:', error);
closeGuard.confirmClose();
win.close();
}
});
}

win.loadFile(path.join(appRootDir, 'index.html'));
return win;
}
21 changes: 21 additions & 0 deletions src/main/ipc/register-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
SystemPreferences
} from 'electron';

import type { CloseGuard } from '../app/close-guard';
import type { createProjectService } from '../services/project-service';
import type { renderComposite } from '../services/render-service';
import type { exportPremiereProject } from '../services/premiere-export-service';
Expand Down Expand Up @@ -44,6 +45,7 @@ export function registerIpcHandlers({
shell,
systemPreferences,
getWindow,
closeGuard,
projectService,
renderComposite,
exportPremiereProject,
Expand All @@ -61,6 +63,7 @@ export function registerIpcHandlers({
shell: Shell;
systemPreferences?: Pick<SystemPreferences, 'askForMediaAccess' | 'getMediaAccessStatus'>;
getWindow: () => BrowserWindow | null;
closeGuard: CloseGuard;
projectService: ProjectService;
renderComposite: RenderComposite;
exportPremiereProject: ExportPremiereProject;
Expand Down Expand Up @@ -437,6 +440,24 @@ export function registerIpcHandlers({
// renderer crash or timeout never drops captured bytes. Each recorder
// (screen, camera) opens an independent write handle keyed by
// (takeId, suffix) and finalizes with an atomic rename.
// Quit-guard bookkeeping: the renderer flips this flag on recording
// start/stop (fire-and-forget) so the window 'close' handler can decide
// synchronously in main whether a recording is at risk.
ipcMain.on('recording:set-active', (_event, active: unknown) => {
closeGuard.setRecordingActive(Boolean(active));
});

// The renderer calls this after the close prompt (recording stopped and
// finalized, or stop failed and the user still wants out). Arms the
// one-shot bypass BEFORE close() so the guard lets the close through.
ipcMain.handle('app:confirm-close', async () => {
closeGuard.confirmClose();
const win = getWindow();
if (!win || win.isDestroyed()) return false;
win.close();
return true;
});

ipcMain.handle('recording:begin', async (_event, payload: unknown) => {
const opts = (payload || {}) as {
takeId?: string;
Expand Down
Loading
Loading