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
42 changes: 42 additions & 0 deletions app/e2e-mock-ipc.js
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,48 @@ function install({ ipcMain }) {
return { success: true, path: seamPath };
},

// Share capability. Injectable precisely because the real answer is a
// platform fact: gating the renderer on this instead of navigator.platform
// is what lets a T1 spec exercise BOTH branches on any OS, including the
// absent branch that keeps Windows away from a ShareMenu it does not have.
'share-capability': async () => process.env.STENOAI_E2E_SHARE_CAPABLE === '1',

// Mirror the real share-note-file handler far enough to observe the call.
// Appends one JSON line per call to STENOAI_E2E_SHARE_LOG so a spec can
// count calls (the pending guard) and read back what was passed. No file is
// materialised and no sheet is popped: that is the real handler's job and
// the T2 spec's assertion. STENOAI_E2E_SHARE_DELAY_MS holds the call open so
// the "Preparing…" state is observable without racing the clock.
//
// STENOAI_E2E_SHARE_PAYLOAD_PATH writes the payload VERBATIM, the same seam
// STENOAI_E2E_EXPORT_PATH gives the save path. The log's 200-char head is
// enough to tell a PDF from markdown, but a branded PDF's first 200 chars
// are doctype and font CSS — the note itself is thousands of characters in,
// so asserting WHICH note was shared needs the whole payload.
'share-note-file': async (_event, kind, defaultFilename, payload, anchor) => {
const logPath = process.env.STENOAI_E2E_SHARE_LOG;
if (logPath) {
fs.appendFileSync(
logPath,
JSON.stringify({
kind,
defaultFilename,
anchor,
payloadLength: typeof payload === 'string' ? payload.length : null,
payloadHead: typeof payload === 'string' ? payload.slice(0, 200) : null,
}) + '\n',
'utf-8',
);
}
const payloadPath = process.env.STENOAI_E2E_SHARE_PAYLOAD_PATH;
if (payloadPath && typeof payload === 'string') {
fs.writeFileSync(payloadPath, payload, 'utf-8');
}
const delay = Number(process.env.STENOAI_E2E_SHARE_DELAY_MS || 0);
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
return { success: true };
},

'org-status': async () => {
if (!state.orgSession) {
return { signedIn: false, everSignedIn: state.everSignedIn };
Expand Down
182 changes: 173 additions & 9 deletions app/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const { isOSUpdateEligible, MIN_MACOS_FOR_AUTOUPDATE } = require('./update-os-ga
const processingLog = require('./processing-log');
const { isMeetingApp, allowsDeviceLevelFallback, isMacos14Plus } = require('./meeting-detect');
const { sweepOrphanedLiveSnapshots } = require('./live-snapshot-sweep');
const { shareTempDir, sweepShareTemp, SHARE_TEMP_MAX_AGE_MS } = require('./share-temp');
const { userNotesFilePath } = require('./notes-file');
const { makeLineReader } = require('./backend-stream');
// Pure deep-link (stenoai://) parsing/sanitizing lives in ./shortcut-url
Expand Down Expand Up @@ -113,6 +114,24 @@ if (process.env.STENOAI_USER_DATA_DIR) {
app.setPath('userData', process.env.STENOAI_USER_DATA_DIR);
}
const IS_E2E = process.env.STENOAI_E2E === '1';
// The subset of the harness that redirects WHERE BYTES GO: the STENOAI_E2E_*_PATH
// seams in export-transcript / export-note-pdf / save-diagnostics, and the
// path-returning seam in share-note-file. Those need a second condition beyond
// IS_E2E, because IS_E2E is nothing but an environment variable: a signed build
// started with STENOAI_E2E=1 plus one of those paths would silently write a
// user's export to an attacker-chosen location instead of the file they picked
// in the save dialog, and never show the dialog at all.
//
// `!app.isPackaged` is the right second condition rather than a stricter one:
// every e2e lane launches the DEV binary from source (`electron.launch({ args:
// ['.'] })` in e2e/fixtures/electron.ts, and the release gate's T1 smoke runs
// the same way), so no legitimate test run is packaged. A packaged build now
// ignores these seams entirely, whatever it is started with.
//
// Deliberately NOT folded into IS_E2E itself: its other ~30 uses gate the tray,
// the dock, the scheduler and telemetry, and re-gating those on packaging would
// be a far wider behavioural change than closing this hole.
const ALLOW_E2E_PATH_SEAMS = IS_E2E && !app.isPackaged;
const IS_E2E_MOCK_IPC = process.env.STENOAI_E2E_MOCK_IPC === '1';

// Global (system-wide) accelerator to toggle recording. CommandOrControl
Expand Down Expand Up @@ -2182,6 +2201,25 @@ if (!gotSingleInstanceLock) {
console.warn('Live-snapshot sweep failed (non-fatal):', e?.message);
}

// Sweep share-sheet temp files older than a day. This is the ONLY cleanup
// for that directory: a file handed to ShareMenu must survive as long as the
// destination holds it (an open Mail draft, an AirDrop transfer waiting to be
// accepted), and popup() never reports when that ends - so nothing is deleted
// during a session. See share-temp.js for the full reasoning. Best-effort: an
// undeletable temp directory must not interrupt a launch.
try {
const { deleted } = sweepShareTemp(
shareTempDir(app.getPath('temp')),
Date.now(),
SHARE_TEMP_MAX_AGE_MS,
);
if (deleted.length > 0) {
sendDebugLog(`Swept ${deleted.length} stale share temp file(s)`);
}
} catch (e) {
console.warn('Share temp sweep failed (non-fatal):', e?.message);
}

// Load custom storage path for file validation. Skipped under E2E (spawns
// the backend; the test tiers keep startup backend-free).
if (!IS_E2E) {
Expand Down Expand Up @@ -3759,9 +3797,10 @@ ipcMain.handle('export-transcript', async (event, defaultFilename, content) => {
return { success: false, error: 'No transcript content to export.' };
}

// Test-only seam: only honor it under e2e, so a stray env var in a real
// launch can't silently redirect a user's export to an arbitrary path.
const seamPath = IS_E2E ? process.env.STENOAI_E2E_EXPORT_PATH : undefined;
// Test-only seam: honored only in an UNPACKAGED e2e run, so a stray env var
// in a real launch can't silently redirect a user's export to an arbitrary
// path. See ALLOW_E2E_PATH_SEAMS - IS_E2E alone is just an env var.
const seamPath = ALLOW_E2E_PATH_SEAMS ? process.env.STENOAI_E2E_EXPORT_PATH : undefined;
let targetPath = seamPath;

if (!targetPath) {
Expand Down Expand Up @@ -3883,9 +3922,10 @@ ipcMain.handle('export-note-pdf', async (event, defaultFilename, html) => {
return { success: false, error: 'No notes content to export.' };
}

// Test-only seam: only honor it under e2e, so a stray env var in a real
// launch can't silently redirect a user's export to an arbitrary path.
const seamPath = IS_E2E ? process.env.STENOAI_E2E_EXPORT_PATH : undefined;
// Test-only seam: honored only in an UNPACKAGED e2e run (see
// ALLOW_E2E_PATH_SEAMS), so a stray env var in a real launch can't silently
// redirect a user's export to an arbitrary path.
const seamPath = ALLOW_E2E_PATH_SEAMS ? process.env.STENOAI_E2E_EXPORT_PATH : undefined;
let targetPath = seamPath;

if (!targetPath) {
Expand Down Expand Up @@ -3937,9 +3977,10 @@ ipcMain.handle('save-diagnostics', async (event, defaultFilename, content) => {
return { success: false, error: 'No diagnostics content to save.' };
}

// Test-only seam: only honor it under e2e, so a stray env var in a real
// launch can't silently redirect a user's save to an arbitrary path.
const seamPath = IS_E2E ? process.env.STENOAI_E2E_DIAGNOSTICS_PATH : undefined;
// Test-only seam: honored only in an UNPACKAGED e2e run (see
// ALLOW_E2E_PATH_SEAMS), so a stray env var in a real launch can't silently
// redirect a user's save to an arbitrary path.
const seamPath = ALLOW_E2E_PATH_SEAMS ? process.env.STENOAI_E2E_DIAGNOSTICS_PATH : undefined;
let targetPath = seamPath;

if (!targetPath) {
Expand Down Expand Up @@ -3978,6 +4019,129 @@ ipcMain.handle('save-diagnostics', async (event, defaultFilename, content) => {
}
});

// Whether a native share sheet actually exists on this machine. macOS-only:
// Electron exposes ShareMenu on darwin and nothing anywhere else. The second
// half of the check is not paranoia - if a future Electron drops or renames the
// export, this degrades to a hidden menu group instead of a main-process crash
// on `new undefined(...)`.
function shareSheetAvailable() {
if (process.platform !== 'darwin') return false;
try {
return typeof require('electron').ShareMenu === 'function';
} catch (_) {
return false;
}
}

// The renderer asks once and hides the three share entries when this is false.
// That flag is only a hint for what to draw; the handler below re-checks, which
// is what actually keeps a stray call from crashing the process.
ipcMain.handle('share-capability', () => shareSheetAvailable());

// Hand a note to the native macOS share sheet (AirDrop, Mail, Messages, Notes).
// Follows the same split as the three export handlers above: the renderer builds
// the content, we own the bytes. Differences from them, all deliberate:
// - No save dialog. The destination is our own managed temp directory, because
// the sheet needs a real file and the user never picks a location.
// - The filename is user-visible: it becomes the attachment name the recipient
// reads, so defaultExportFilename()'s dated slug is used as-is.
// - Files are never deleted during the session (see share-temp.js).
// Returns { success } only; success means the sheet opened, since popup() has no
// completion callback and the app never learns the destination.
ipcMain.handle('share-note-file', async (event, kind, defaultFilename, payload, anchor) => {
try {
if (kind !== 'pdf' && kind !== 'text') {
return { success: false, error: 'Unsupported share type.' };
}
if (typeof payload !== 'string' || payload.length === 0) {
return { success: false, error: 'No content to share.' };
}

// Fail before rendering: a stray call off darwin must not pay for a 15
// second PDF render just to be refused. In an unpackaged e2e run we
// deliberately continue on every platform, because the file write is what
// those specs assert and T2 runs on Windows too.
if (!ALLOW_E2E_PATH_SEAMS && !shareSheetAvailable()) {
return { success: false, error: 'Sharing is not available on this platform.' };
}

// The renderer supplies a suggested name only. Unlike the export handlers,
// where the reduced name merely seeds a dialog the user confirms, here it is
// joined onto a directory we write into - so `.` and `..` have to be rejected
// as well, not just directory components.
const fallback = kind === 'pdf' ? 'notes.pdf' : 'notes.md';
let base = fallback;
if (typeof defaultFilename === 'string' && defaultFilename.trim()) {
const reduced = path.basename(defaultFilename).slice(0, 200);
if (reduced && reduced !== '.' && reduced !== '..') base = reduced;
}

const dir = shareTempDir(app.getPath('temp'));
const targetPath = path.join(dir, base);

// Rasterise BEFORE touching the destination, so a render failure leaves no
// file behind. Mirrors export-note-pdf.
const bytes = kind === 'pdf' ? await renderHtmlToPdf(payload) : payload;

// Atomic write: tmp file in the SAME directory, then rename into place.
// Sharing the same note twice overwrites the first file, which is fine: the
// content is identical and Mail copies an attachment into the draft. The
// exception, since the name is deliberately not randomised: another process
// running as the same user could have planted a file of that name in this
// directory first, and the rename would replace it. Accepted - the filename
// has to stay the readable one the recipient sees, and anyone who can write
// here can already read the notes we put here.
const tmpPath = path.join(dir, `.${base}.${require('crypto').randomBytes(6).toString('hex')}.tmp`);
try {
await fs.promises.writeFile(tmpPath, bytes, kind === 'pdf' ? undefined : 'utf-8');
await fs.promises.rename(tmpPath, targetPath);
} catch (writeErr) {
try { await fs.promises.unlink(tmpPath); } catch (_) {}
throw writeErr;
}

// Test-only seam, mirroring STENOAI_E2E_EXPORT_PATH: a native sheet cannot be
// automated (Playwright can neither see nor dismiss it, and an open sheet
// blocks the run), so under e2e we stop after the write and return where the
// file went. The path is returned ONLY here - outside the seam the renderer
// has no business knowing, and a leaked absolute path is exactly what the
// basename reduction exists to prevent.
if (ALLOW_E2E_PATH_SEAMS) return { success: true, path: targetPath };

// Re-check rather than trust the entry gate above: this is the line that
// stops `new undefined(...)` from taking the main process down.
if (!shareSheetAvailable()) {
return { success: false, error: 'Sharing is not available on this platform.' };
}
if (!mainWindow || mainWindow.isDestroyed()) {
return { success: false, error: 'No window to share from.' };
}
const { ShareMenu } = require('electron');
const menu = new ShareMenu({ filePaths: [targetPath] });
// Anchor on the clicked entry so the sheet pops from the button rather than
// the window corner. The rectangle was read up to 15 seconds ago, before the
// PDF render, so a scroll, a window move or a resize can have moved it, and
// after a shrink it can sit outside the window entirely. An out-of-range
// anchor is worse than none: dropping it lets Electron place the sheet with
// coordinates that are at least current. A malformed anchor is dropped for
// the same reason rather than defaulted to 0,0.
const popupOptions = { window: mainWindow };
if (anchor && Number.isFinite(anchor.x) && Number.isFinite(anchor.y)) {
const { width, height } = mainWindow.getContentBounds();
const x = Math.round(anchor.x);
const y = Math.round(anchor.y);
if (x >= 0 && y >= 0 && x <= width && y <= height) {
popupOptions.x = x;
popupOptions.y = y;
}
}
menu.popup(popupOptions);
return { success: true };
} catch (err) {
return { success: false, error: String(err && err.message ? err.message : err) };
}
});

// Replace (or remove, when empty) the "## User Notes" section at the tail of a
// meeting markdown body. Kept a pure string transform so it's unit-testable and
// never touches the summary/transcript sections above it. `body` is everything
Expand Down
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"typecheck:renderer": "tsc -p renderer/tsconfig.json --noEmit",
"lint:renderer": "eslint --config renderer/eslint.config.mjs renderer/src",
"format:renderer": "prettier --write renderer/src",
"test:unit": "node --test processing-log.test.js meeting-detect.test.js notes-file.test.js backend-stream.test.js ipc-contract.test.js shortcut-url.test.js setup-check-parse.test.js diagnostics-forward.test.js analytics-helpers.test.js live-snapshot-sweep.test.js backend-cli.test.js debug-log.test.js teardown.test.js folders-ipc.test.js settings-ipc.test.js regen-title-busy-guard.test.js update-idle-gate.test.js update-os-gate.test.js update-error-copy.test.js && vitest run",
"test:unit": "node --test processing-log.test.js meeting-detect.test.js notes-file.test.js backend-stream.test.js ipc-contract.test.js shortcut-url.test.js setup-check-parse.test.js diagnostics-forward.test.js analytics-helpers.test.js live-snapshot-sweep.test.js backend-cli.test.js debug-log.test.js teardown.test.js folders-ipc.test.js settings-ipc.test.js regen-title-busy-guard.test.js update-idle-gate.test.js update-os-gate.test.js update-error-copy.test.js share-temp.test.js && vitest run",
"build": "npm run build:renderer && electron-builder",
"pack:unsigned": "npm run build:renderer && electron-builder --dir --config electron-builder.ci.yml",
"build-mac": "npm run build:renderer && electron-builder --mac",
Expand Down
10 changes: 10 additions & 0 deletions app/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,16 @@ const stenoai = {
chatStream: (streamId, payload) => send('org-chat-stream', streamId, payload),
},

// Native macOS share sheet. canShare answers "does a share sheet exist here",
// NOT "does the user agent look like a Mac" - the renderer's isMac is a
// module-level navigator.platform constant frozen at import, so it can neither
// be flipped at runtime nor exercised in both directions by a test.
share: {
canShare: () => invoke('share-capability'),
shareFile: (kind, defaultFilename, payload, anchor) =>
invoke('share-note-file', kind, defaultFilename, payload, anchor),
},

dialog: {
respondQuit: (confirmed) => send('quit-dialog-response', { confirmed }),
},
Expand Down
21 changes: 21 additions & 0 deletions app/renderer/src/lib/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1193,6 +1193,27 @@ export interface StenoaiBridge {
chatStream: SendFn<[streamId: string, payload: OrgChatPayload]>;
};

share: {
/** Whether a native share sheet exists on this machine (macOS only). Treat
* an unresolved query as false so the entries never flash in before the
* capability is known. */
canShare: RequestFn<[], boolean>;
/** Write the payload into the managed share temp directory and pop the
* native sheet on it. `kind: 'pdf'` takes HTML and rasterises it first;
* `'text'` is written as UTF-8. `anchor` is a point in window coordinates
* so the sheet pops from the clicked entry. `success` means only that the
* sheet opened - the destination is never reported back. */
shareFile: RequestFn<
[
kind: 'pdf' | 'text',
defaultFilename: string,
payload: string,
anchor: { x: number; y: number },
],
Result<Record<string, never>>
>;
};

dialog: {
respondQuit: SendFn<[confirmed: boolean]>;
};
Expand Down
Loading
Loading