From 4d1e9358fe519b1562c894a272be82d6a94cbd7b Mon Sep 17 00:00:00 2001 From: Optic00 Date: Thu, 30 Jul 2026 16:50:54 +0200 Subject: [PATCH 1/9] feat(notes): add buildNotesMarkdown export --- app/renderer/src/lib/notesMarkdown.test.ts | 129 +++++++++++++++++++++ app/renderer/src/lib/notesMarkdown.ts | 49 ++++++++ 2 files changed, 178 insertions(+) create mode 100644 app/renderer/src/lib/notesMarkdown.test.ts create mode 100644 app/renderer/src/lib/notesMarkdown.ts diff --git a/app/renderer/src/lib/notesMarkdown.test.ts b/app/renderer/src/lib/notesMarkdown.test.ts new file mode 100644 index 00000000..44dcf921 --- /dev/null +++ b/app/renderer/src/lib/notesMarkdown.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from 'vitest'; +import type { StructuredNoteSections } from './notesCopy'; +import { buildNotesMarkdown } from './notesMarkdown'; + +const fullSections: StructuredNoteSections = { + name: 'Weekly *sync*', + meta: 'Mon, Jun 23, 2026, 10:00 AM · 45m', + summary: 'We discussed **the roadmap**.', + discussionAreas: [ + { + title: 'Roadmap & scope', + analysis: 'Q3 priorities [agreed](https://example.com/roadmap).', + }, + { title: 'Hiring' }, + ], + keyPoints: ['Ship `v2` in July'], + actionItems: ['Ben: prepare '], + participants: ['Ben', 'Ruzin & Alex'], +}; + +describe('buildNotesMarkdown', () => { + test('renders every structured field in copy-export order without escaping body text', () => { + expect(buildNotesMarkdown(fullSections, null)).toBe( + [ + '# Weekly *sync*', + 'Mon, Jun 23, 2026, 10:00 AM · 45m', + '', + '## Summary', + '', + 'We discussed **the roadmap**.', + '', + '## Key Topics', + '', + '### Roadmap & scope', + '', + 'Q3 priorities [agreed](https://example.com/roadmap).', + '', + '### Hiring', + '', + '## Key Points', + '', + '- Ship `v2` in July', + '', + '## Action Items', + '', + '- Ben: prepare ', + '', + '## Participants', + '', + 'Ben, Ruzin & Alex', + ].join('\n'), + ); + }); + + test('removing any handled structured field changes the output', () => { + const complete = buildNotesMarkdown(fullSections, null); + const removals: Array<[string, StructuredNoteSections]> = [ + ['title', { ...fullSections, name: '' }], + ['meta', { ...fullSections, meta: undefined }], + ['summary', { ...fullSections, summary: undefined }], + [ + 'discussion title', + { + ...fullSections, + discussionAreas: [ + { analysis: fullSections.discussionAreas[0].analysis, title: '' }, + fullSections.discussionAreas[1], + ], + }, + ], + [ + 'discussion analysis', + { + ...fullSections, + discussionAreas: [ + { title: fullSections.discussionAreas[0].title }, + fullSections.discussionAreas[1], + ], + }, + ], + ['discussion areas', { ...fullSections, discussionAreas: [] }], + ['key points', { ...fullSections, keyPoints: [] }], + ['action items', { ...fullSections, actionItems: [] }], + ['participants', { ...fullSections, participants: [] }], + ]; + + removals.forEach(([field, sections]) => { + expect(buildNotesMarkdown(sections, null), field).not.toBe(complete); + }); + }); + + test('omits every empty section without leaving a heading', () => { + const markdown = buildNotesMarkdown( + { + name: 'Empty note', + summary: ' ', + discussionAreas: [], + keyPoints: [], + actionItems: [], + participants: [], + }, + null, + ); + + expect(markdown).toBe('# Empty note'); + expect(markdown).not.toContain('## Summary'); + expect(markdown).not.toContain('## Key Topics'); + expect(markdown).not.toContain('## Key Points'); + expect(markdown).not.toContain('## Action Items'); + expect(markdown).not.toContain('## Participants'); + }); + + test('an open active report preserves title and meta while replacing the structured body', () => { + const content = '## 1:1 Notes\n\n- Roadmap \n'; + const markdown = buildNotesMarkdown(fullSections, { content }); + + expect(markdown).toBe( + [ + '# Weekly *sync*', + 'Mon, Jun 23, 2026, 10:00 AM · 45m', + '', + content, + ].join('\n'), + ); + expect(markdown).not.toContain('## Summary'); + expect(markdown).not.toContain('## Key Topics'); + expect(buildNotesMarkdown(fullSections, { content: '' })).not.toBe(markdown); + }); +}); diff --git a/app/renderer/src/lib/notesMarkdown.ts b/app/renderer/src/lib/notesMarkdown.ts new file mode 100644 index 00000000..f16379c0 --- /dev/null +++ b/app/renderer/src/lib/notesMarkdown.ts @@ -0,0 +1,49 @@ +import type { StructuredNoteSections } from './notesCopy'; + +function hasText(value: string | undefined): value is string { + return Boolean(value?.trim()); +} + +export function buildNotesMarkdown( + sections: StructuredNoteSections, + activeReport: { content: string } | null, +): string { + const titleBlock = [`# ${sections.name}`]; + if (sections.meta) titleBlock.push(sections.meta); + + const blocks: string[] = [titleBlock.join('\n')]; + + if (activeReport) { + if (hasText(activeReport.content)) blocks.push(activeReport.content); + return blocks.join('\n\n'); + } + + if (hasText(sections.summary)) { + blocks.push(`## Summary\n\n${sections.summary}`); + } + + if (sections.discussionAreas.length) { + const topics = sections.discussionAreas + .map((area) => { + const topic = [`### ${area.title}`]; + if (hasText(area.analysis)) topic.push('', area.analysis); + return topic.join('\n'); + }) + .join('\n\n'); + blocks.push(`## Key Topics\n\n${topics}`); + } + + if (sections.keyPoints.length) { + blocks.push(`## Key Points\n\n${sections.keyPoints.map((point) => `- ${point}`).join('\n')}`); + } + + if (sections.actionItems.length) { + blocks.push(`## Action Items\n\n${sections.actionItems.map((item) => `- ${item}`).join('\n')}`); + } + + if (sections.participants.length) { + blocks.push(`## Participants\n\n${sections.participants.join(', ')}`); + } + + return blocks.join('\n\n'); +} From 74cdc8627b334043b35bc71fabdf9bbffcc1e153 Mon Sep 17 00:00:00 2001 From: Optic00 Date: Thu, 30 Jul 2026 18:24:26 +0200 Subject: [PATCH 2/9] feat(share): add share temp directory helper and startup sweep The share sheet needs a real file on disk whose name the recipient reads. Files land in /stenoai-share/ and are swept only at the next start, past 24 hours: deleting earlier would pull an attachment out from under an open mail draft, and ShareMenu.popup() never tells the app when that ends. --- app/package.json | 2 +- app/share-temp.js | 104 +++++++++++++++++++++++++++ app/share-temp.test.js | 160 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 app/share-temp.js create mode 100644 app/share-temp.test.js diff --git a/app/package.json b/app/package.json index 46f3463d..57b16d80 100644 --- a/app/package.json +++ b/app/package.json @@ -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 && 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 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", diff --git a/app/share-temp.js b/app/share-temp.js new file mode 100644 index 00000000..ea3b8abc --- /dev/null +++ b/app/share-temp.js @@ -0,0 +1,104 @@ +'use strict'; + +const fsDefault = require('fs'); +const path = require('path'); + +// Temp files handed to the native macOS share sheet (ShareMenu). The +// `share-note-file` handler in main.js materialises a note as a PDF or a +// markdown file here, then pops the sheet on it. +// +// Why this directory is swept at startup and NEVER during a session: the +// filename becomes the attachment name the recipient reads, and the file has to +// survive the whole time the destination holds it. A user who picks Mail has an +// open draft and may type for ten minutes; AirDrop waits for the receiving side +// to accept. ShareMenu.popup() has no completion callback, so the app is never +// told when that ends. Deleting on sheet close, or on quit, would pull the +// attachment out from under an open draft — an open draft at quit time is not an +// exotic case. Sweeping only files older than a day at the NEXT start means +// files outlive their own session including any draft, the directory still +// cannot grow without bound, and there is no moment at which the app deletes a +// file someone is still reading. +// +// No electron import: the caller passes app.getPath('temp') in. That is what +// keeps this testable under plain node:test. + +// Fixed subdirectory name so the sweep can own everything inside it. Because +// the directory is ours by construction there is no filename pattern to match +// (unlike live-snapshot-sweep, which sweeps the shared os.tmpdir()), so a +// crashed atomic write's leftover `...tmp` is reclaimed too. +const SHARE_TEMP_DIRNAME = 'stenoai-share'; + +const SHARE_TEMP_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +// Resolve (and create) the share temp directory under baseTempPath. Called on +// every share as well as at startup, so it must never disturb existing files. +function shareTempDir(baseTempPath) { + const dir = path.join(baseTempPath, SHARE_TEMP_DIRNAME); + fsDefault.mkdirSync(dir, { recursive: true }); + return dir; +} + +// Delete every file in `dir` whose mtime is at least maxAgeMs old, measured +// against the injected `now`. Best-effort throughout: a missing directory +// returns quietly and a per-entry failure is recorded and skipped, so one +// locked file cannot abort the rest. Flat by construction — never recurses. +// +// `fs` is injectable for the deterministic failure test. +// Returns { deleted: string[], kept: string[] } of absolute paths acted on, so +// the caller can log a count. +function sweepShareTemp(dir, now, maxAgeMs = SHARE_TEMP_MAX_AGE_MS, fs = fsDefault) { + const deleted = []; + const kept = []; + + let entries; + try { + entries = fs.readdirSync(dir); + } catch (_) { + // Directory missing or unreadable — nothing to sweep. + return { deleted, kept }; + } + + for (const name of entries) { + const filePath = path.join(dir, name); + + let stat; + try { + // lstat, never stat: act only on the regular files we wrote ourselves and + // never follow a symlink out of the share directory. + stat = fs.lstatSync(filePath); + } catch (_) { + // Vanished between readdir and lstat — already gone. + continue; + } + + // Only plain files. A directory (or any other special file) is left alone + // and not recursed into. + if (!stat.isFile()) { + kept.push(filePath); + continue; + } + + if (now - stat.mtimeMs < maxAgeMs) { + kept.push(filePath); + continue; + } + + try { + fs.unlinkSync(filePath); + deleted.push(filePath); + } catch (_) { + // EPERM (a scanner holding it open on Windows) or an ENOENT race. Count it + // as kept and continue; a best-effort sweep never throws. + kept.push(filePath); + } + } + + return { deleted, kept }; +} + +module.exports = { + shareTempDir, + sweepShareTemp, + SHARE_TEMP_DIRNAME, + SHARE_TEMP_MAX_AGE_MS, +}; diff --git a/app/share-temp.test.js b/app/share-temp.test.js new file mode 100644 index 00000000..80e39410 --- /dev/null +++ b/app/share-temp.test.js @@ -0,0 +1,160 @@ +const { test, after } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + shareTempDir, + sweepShareTemp, + SHARE_TEMP_DIRNAME, + SHARE_TEMP_MAX_AGE_MS, +} = require('./share-temp'); + +const HOUR_MS = 60 * 60 * 1000; + +// Track every temp dir we create so a single after-hook removes them all — +// otherwise repeated runs accumulate share-temp-test-* dirs in the system temp. +const createdTmpDirs = []; + +function makeTmpDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'share-temp-test-')); + createdTmpDirs.push(dir); + return dir; +} + +after(() => { + for (const dir of createdTmpDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// Write a share-shaped file and backdate its mtime by ageMs. Returns the path. +// mtime is set explicitly (never the wall clock) so the age boundary is exact. +function writeShared(dir, name, ageMs, now) { + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, 'payload', 'utf-8'); + const when = (now - ageMs) / 1000; // fs.utimesSync takes seconds + fs.utimesSync(filePath, when, when); + return filePath; +} + +test('shareTempDir creates the subdirectory under the injected base path', () => { + const base = makeTmpDir(); + + const dir = shareTempDir(base); + + assert.strictEqual(dir, path.join(base, SHARE_TEMP_DIRNAME)); + assert.strictEqual(fs.statSync(dir).isDirectory(), true); +}); + +test('shareTempDir is idempotent on an existing directory', () => { + const base = makeTmpDir(); + const first = shareTempDir(base); + fs.writeFileSync(path.join(first, 'keep.md'), 'x', 'utf-8'); + + const second = shareTempDir(base); + + assert.strictEqual(second, first); + // Re-resolving must never wipe what is already in there: a second call + // happens on every share, long after the startup sweep decided what stays. + assert.strictEqual(fs.existsSync(path.join(first, 'keep.md')), true); +}); + +test('deletes a file older than the cutoff', () => { + const dir = makeTmpDir(); + const now = 1_800_000_000_000; + const stale = writeShared(dir, '2026-07-01-old.pdf', 25 * HOUR_MS, now); + + const result = sweepShareTemp(dir, now, SHARE_TEMP_MAX_AGE_MS); + + assert.deepStrictEqual(result.deleted, [stale]); + assert.strictEqual(fs.existsSync(stale), false); +}); + +test('keeps a file younger than the cutoff', () => { + // The load-bearing case. A 23-hour-old file may still be the attachment + // under an open mail draft; deleting it destroys what the user is sending. + const dir = makeTmpDir(); + const now = 1_800_000_000_000; + const fresh = writeShared(dir, '2026-07-30-fresh.pdf', 23 * HOUR_MS, now); + + const result = sweepShareTemp(dir, now, SHARE_TEMP_MAX_AGE_MS); + + assert.deepStrictEqual(result.deleted, []); + assert.deepStrictEqual(result.kept, [fresh]); + assert.strictEqual(fs.existsSync(fresh), true); +}); + +test('the age boundary is exact and deterministic', () => { + const dir = makeTmpDir(); + const now = 1_800_000_000_000; + + // age === maxAgeMs is not younger, so it goes. + const atBoundary = writeShared(dir, 'boundary.md', 5000, now); + assert.deepStrictEqual(sweepShareTemp(dir, now, 5000).deleted, [atBoundary]); + + // One millisecond inside the window keeps it. + writeShared(dir, 'boundary.md', 4999, now); + const inside = sweepShareTemp(dir, now, 5000); + assert.deepStrictEqual(inside.deleted, []); + assert.deepStrictEqual(inside.kept, [atBoundary]); +}); + +test('a missing directory does not throw', () => { + const missing = path.join(os.tmpdir(), 'share-temp-does-not-exist-xyz'); + + const result = sweepShareTemp(missing, Date.now(), SHARE_TEMP_MAX_AGE_MS); + + assert.deepStrictEqual(result, { deleted: [], kept: [] }); +}); + +test('never removes or recurses into a subdirectory', () => { + const dir = makeTmpDir(); + const now = 1_800_000_000_000; + const nested = path.join(dir, 'nested'); + fs.mkdirSync(nested); + const buried = path.join(nested, 'buried.md'); + fs.writeFileSync(buried, 'x', 'utf-8'); + const when = (now - 99 * HOUR_MS) / 1000; + fs.utimesSync(buried, when, when); + fs.utimesSync(nested, when, when); + + const result = sweepShareTemp(dir, now, SHARE_TEMP_MAX_AGE_MS); + + assert.deepStrictEqual(result.deleted, []); + assert.deepStrictEqual(result.kept, [nested]); + assert.strictEqual(fs.existsSync(buried), true); +}); + +test('one unlinkable entry does not abort the rest of the sweep', () => { + const dir = makeTmpDir(); + const now = 1_800_000_000_000; + const locked = writeShared(dir, 'locked.pdf', 30 * HOUR_MS, now); + const other = writeShared(dir, 'other.pdf', 30 * HOUR_MS, now); + + const stubFs = { + readdirSync: fs.readdirSync, + lstatSync: fs.lstatSync, + unlinkSync: (p) => { + if (p === locked) { + const err = new Error('EPERM'); + err.code = 'EPERM'; + throw err; + } + return fs.unlinkSync(p); + }, + }; + + const result = sweepShareTemp(dir, now, SHARE_TEMP_MAX_AGE_MS, stubFs); + + assert.deepStrictEqual(result.deleted, [other]); + assert.deepStrictEqual(result.kept, [locked]); + assert.strictEqual(fs.existsSync(locked), true); +}); + +test('the default cutoff is 24 hours', () => { + // The spec's contract: files outlive their own session including any open + // draft, and the directory still cannot grow without bound. + assert.strictEqual(SHARE_TEMP_MAX_AGE_MS, 24 * HOUR_MS); +}); From 9056532ca11d434e53f7f5899232cd5fda6a0a30 Mon Sep 17 00:00:00 2001 From: Optic00 Date: Thu, 30 Jul 2026 18:28:23 +0200 Subject: [PATCH 3/9] feat(share): add share-note-file handler and the share bridge The handler materialises a note as a PDF or markdown file in the managed share temp directory and pops the native macOS sheet on it. Capability is answered by the main process rather than the renderer's isMac, which is a navigator.platform constant frozen at import and untestable in both directions. The platform is checked twice on purpose: the renderer flag decides what to draw, this check is what stops a stray call from taking the main process down on 'new undefined(...)'. --- app/main.js | 126 ++++++++++++++++++++++++++++++++++++ app/preload.js | 10 +++ app/renderer/src/lib/ipc.ts | 21 ++++++ 3 files changed, 157 insertions(+) diff --git a/app/main.js b/app/main.js index f8b9df6b..2b7928dd 100644 --- a/app/main.js +++ b/app/main.js @@ -61,6 +61,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 @@ -2105,6 +2106,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) { @@ -3903,6 +3923,112 @@ 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. Under e2e we deliberately continue + // on every platform, because the file write is what those specs assert and + // T2 runs on Windows too. + if (!IS_E2E && !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. + 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 (IS_E2E) 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.' }; + } + 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. A malformed anchor is dropped, not defaulted to 0,0 — + // Electron then places the sheet itself. + const popupOptions = { window: mainWindow }; + if (anchor && Number.isFinite(anchor.x) && Number.isFinite(anchor.y)) { + popupOptions.x = Math.round(anchor.x); + popupOptions.y = Math.round(anchor.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 diff --git a/app/preload.js b/app/preload.js index 9abee106..764a36fd 100644 --- a/app/preload.js +++ b/app/preload.js @@ -343,6 +343,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 }), }, diff --git a/app/renderer/src/lib/ipc.ts b/app/renderer/src/lib/ipc.ts index b0bdbe1d..0bc6eb00 100644 --- a/app/renderer/src/lib/ipc.ts +++ b/app/renderer/src/lib/ipc.ts @@ -1154,6 +1154,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> + >; + }; + dialog: { respondQuit: SendFn<[confirmed: boolean]>; }; From b978b2ab952bbc80b8afe27211f30edf3f66f0c9 Mon Sep 17 00:00:00 2001 From: Optic00 Date: Thu, 30 Jul 2026 18:44:27 +0200 Subject: [PATCH 4/9] feat(notes): collect the note exports in one Share menu Both copy icons leave the toolbar and the two file saves leave the ... menu, which keeps its management actions. Adds Save notes as .md, which needs no main-process code: export-transcript already takes an arbitrary string. The notes .md filename carries a -notes suffix so it cannot collide with the transcript's; on the share path both land in one directory where the second write would silently replace the first, including under an open mail draft. A copy's auto-close is cancelled on any open/close in between - the timer belongs to the menu instance the copy happened in, and left pending it shut a freshly reopened menu. Covered by a regression spec. --- app/renderer/src/routes/MeetingDetail.tsx | 215 +++++++++++++++++----- e2e/fixtures/share-menu.ts | 19 ++ e2e/specs/copy-notes-report.t1.spec.ts | 9 +- e2e/specs/generate-notes-bar.t1.spec.ts | 2 +- e2e/specs/notes-pdf-export.t1.spec.ts | 18 +- e2e/specs/share-menu.t1.spec.ts | 188 +++++++++++++++++++ e2e/specs/transcript-export.t1.spec.ts | 36 ++-- 7 files changed, 410 insertions(+), 77 deletions(-) create mode 100644 e2e/fixtures/share-menu.ts create mode 100644 e2e/specs/share-menu.t1.spec.ts diff --git a/app/renderer/src/routes/MeetingDetail.tsx b/app/renderer/src/routes/MeetingDetail.tsx index 60bf3cac..e0848e1d 100644 --- a/app/renderer/src/routes/MeetingDetail.tsx +++ b/app/renderer/src/routes/MeetingDetail.tsx @@ -17,6 +17,7 @@ import { Mic, PencilLine, RefreshCw, + Share, Trash2, Users, } from 'lucide-react'; @@ -66,6 +67,7 @@ import { useActiveMeeting } from '@/lib/askBarContext'; import { ipc, type Meeting, type Report, type Template } from '@/lib/ipc'; import { buildTranscriptBundle, defaultExportFilename } from '@/lib/transcriptBundle'; import { buildNotesCopyText, type StructuredNoteSections } from '@/lib/notesCopy'; +import { buildNotesMarkdown } from '@/lib/notesMarkdown'; import { buildNotesHtml, hasNotesContent } from '@/lib/notesPdf'; import { unwrap } from '@/lib/result'; import { cn } from '@/lib/utils'; @@ -77,6 +79,28 @@ import { useRecording } from '@/hooks/useRecording'; const LAST_OPENED_KEY = 'steno-last-opened-meeting'; +// The `…` popover's entry markup, lifted to a constant so the Share menu is +// visually the same control rather than a near-copy that drifts from it. Both +// popovers are plain `Popover` + buttons (no roving focus); giving them +// keyboard navigation is an improvement to that shared pattern, not part of the +// Share menu. +const MENU_ENTRY_CLASS = + 'flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-[color:var(--surface-hover)] disabled:opacity-50'; + +// Group separator inside the Share menu. aria-hidden with role="none": it is +// pure visual grouping, and a `separator` role in a popover that is not a real +// menu would announce structure the keyboard cannot navigate. +function MenuDivider() { + return ( +