markupr 2.1.8 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/README.md +292 -15
  2. package/dist/cli/index.mjs +3593 -0
  3. package/dist/main/index.mjs +743 -220
  4. package/dist/mcp/index.mjs +4053 -0
  5. package/package.json +32 -7
  6. package/.claude/commands/review-feedback.md +0 -47
  7. package/.eslintrc.json +0 -35
  8. package/.github/CODEOWNERS +0 -16
  9. package/.github/FUNDING.yml +0 -1
  10. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -56
  11. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -54
  12. package/.github/PULL_REQUEST_TEMPLATE.md +0 -89
  13. package/.github/dependabot.yml +0 -70
  14. package/.github/workflows/ci.yml +0 -184
  15. package/.github/workflows/deploy-landing.yml +0 -134
  16. package/.github/workflows/nightly.yml +0 -288
  17. package/.github/workflows/release.yml +0 -318
  18. package/CHANGELOG.md +0 -127
  19. package/CLAUDE.md +0 -137
  20. package/CODE_OF_CONDUCT.md +0 -9
  21. package/CONTRIBUTING.md +0 -390
  22. package/PRODUCT_VISION.md +0 -277
  23. package/SECURITY.md +0 -51
  24. package/SIGNING_INSTRUCTIONS.md +0 -284
  25. package/assets/DMG_BACKGROUND_INSTRUCTIONS.md +0 -130
  26. package/assets/svg-source/dmg-background.svg +0 -70
  27. package/assets/svg-source/icon.svg +0 -20
  28. package/assets/svg-source/tray-icon-processing.svg +0 -7
  29. package/assets/svg-source/tray-icon-recording.svg +0 -7
  30. package/assets/svg-source/tray-icon.svg +0 -6
  31. package/assets/tray-complete.png +0 -0
  32. package/assets/tray-complete@2x.png +0 -0
  33. package/assets/tray-completeTemplate.png +0 -0
  34. package/assets/tray-completeTemplate@2x.png +0 -0
  35. package/assets/tray-error.png +0 -0
  36. package/assets/tray-error@2x.png +0 -0
  37. package/assets/tray-errorTemplate.png +0 -0
  38. package/assets/tray-errorTemplate@2x.png +0 -0
  39. package/assets/tray-icon-processing.png +0 -0
  40. package/assets/tray-icon-processing@2x.png +0 -0
  41. package/assets/tray-icon-processingTemplate.png +0 -0
  42. package/assets/tray-icon-processingTemplate@2x.png +0 -0
  43. package/assets/tray-icon-recording.png +0 -0
  44. package/assets/tray-icon-recording@2x.png +0 -0
  45. package/assets/tray-icon-recordingTemplate.png +0 -0
  46. package/assets/tray-icon-recordingTemplate@2x.png +0 -0
  47. package/assets/tray-icon.png +0 -0
  48. package/assets/tray-icon@2x.png +0 -0
  49. package/assets/tray-iconTemplate.png +0 -0
  50. package/assets/tray-iconTemplate@2x.png +0 -0
  51. package/assets/tray-idle.png +0 -0
  52. package/assets/tray-idle@2x.png +0 -0
  53. package/assets/tray-idleTemplate.png +0 -0
  54. package/assets/tray-idleTemplate@2x.png +0 -0
  55. package/assets/tray-processing-0.png +0 -0
  56. package/assets/tray-processing-0@2x.png +0 -0
  57. package/assets/tray-processing-0Template.png +0 -0
  58. package/assets/tray-processing-0Template@2x.png +0 -0
  59. package/assets/tray-processing-1.png +0 -0
  60. package/assets/tray-processing-1@2x.png +0 -0
  61. package/assets/tray-processing-1Template.png +0 -0
  62. package/assets/tray-processing-1Template@2x.png +0 -0
  63. package/assets/tray-processing-2.png +0 -0
  64. package/assets/tray-processing-2@2x.png +0 -0
  65. package/assets/tray-processing-2Template.png +0 -0
  66. package/assets/tray-processing-2Template@2x.png +0 -0
  67. package/assets/tray-processing-3.png +0 -0
  68. package/assets/tray-processing-3@2x.png +0 -0
  69. package/assets/tray-processing-3Template.png +0 -0
  70. package/assets/tray-processing-3Template@2x.png +0 -0
  71. package/assets/tray-processing.png +0 -0
  72. package/assets/tray-processing@2x.png +0 -0
  73. package/assets/tray-processingTemplate.png +0 -0
  74. package/assets/tray-processingTemplate@2x.png +0 -0
  75. package/assets/tray-recording.png +0 -0
  76. package/assets/tray-recording@2x.png +0 -0
  77. package/assets/tray-recordingTemplate.png +0 -0
  78. package/assets/tray-recordingTemplate@2x.png +0 -0
  79. package/build/DMG_BACKGROUND_SPEC.md +0 -50
  80. package/build/dmg-background.png +0 -0
  81. package/build/dmg-background@2x.png +0 -0
  82. package/build/entitlements.mac.inherit.plist +0 -27
  83. package/build/entitlements.mac.plist +0 -41
  84. package/build/favicon-16.png +0 -0
  85. package/build/favicon-180.png +0 -0
  86. package/build/favicon-192.png +0 -0
  87. package/build/favicon-32.png +0 -0
  88. package/build/favicon-48.png +0 -0
  89. package/build/favicon-512.png +0 -0
  90. package/build/favicon-64.png +0 -0
  91. package/build/icon-128.png +0 -0
  92. package/build/icon-16.png +0 -0
  93. package/build/icon-24.png +0 -0
  94. package/build/icon-256.png +0 -0
  95. package/build/icon-32.png +0 -0
  96. package/build/icon-48.png +0 -0
  97. package/build/icon-64.png +0 -0
  98. package/build/icon.icns +0 -0
  99. package/build/icon.ico +0 -0
  100. package/build/icon.iconset/icon_128x128.png +0 -0
  101. package/build/icon.iconset/icon_128x128@2x.png +0 -0
  102. package/build/icon.iconset/icon_16x16.png +0 -0
  103. package/build/icon.iconset/icon_16x16@2x.png +0 -0
  104. package/build/icon.iconset/icon_256x256.png +0 -0
  105. package/build/icon.iconset/icon_256x256@2x.png +0 -0
  106. package/build/icon.iconset/icon_32x32.png +0 -0
  107. package/build/icon.iconset/icon_32x32@2x.png +0 -0
  108. package/build/icon.iconset/icon_512x512.png +0 -0
  109. package/build/icon.iconset/icon_512x512@2x.png +0 -0
  110. package/build/icon.png +0 -0
  111. package/build/installer-header.bmp +0 -0
  112. package/build/installer-header.png +0 -0
  113. package/build/installer-sidebar.bmp +0 -0
  114. package/build/installer-sidebar.png +0 -0
  115. package/build/installer.nsh +0 -45
  116. package/build/overlay-processing.png +0 -0
  117. package/build/overlay-recording.png +0 -0
  118. package/build/toolbar-record.png +0 -0
  119. package/build/toolbar-screenshot.png +0 -0
  120. package/build/toolbar-settings.png +0 -0
  121. package/build/toolbar-stop.png +0 -0
  122. package/dist/preload/index.mjs +0 -907
  123. package/dist/renderer/assets/index-CCmUjl9K.js +0 -19495
  124. package/dist/renderer/assets/index-CUqz_Gs6.css +0 -2270
  125. package/dist/renderer/index.html +0 -27
  126. package/docs/AI_AGENT_QUICKSTART.md +0 -42
  127. package/docs/AI_PIPELINE_DESIGN.md +0 -595
  128. package/docs/API.md +0 -514
  129. package/docs/ARCHITECTURE.md +0 -460
  130. package/docs/CONFIGURATION.md +0 -336
  131. package/docs/DEVELOPMENT.md +0 -508
  132. package/docs/EXPORT_FORMATS.md +0 -451
  133. package/docs/GETTING_STARTED.md +0 -236
  134. package/docs/KEYBOARD_SHORTCUTS.md +0 -334
  135. package/docs/TROUBLESHOOTING.md +0 -418
  136. package/docs/landing/index.html +0 -672
  137. package/docs/landing/script.js +0 -342
  138. package/docs/landing/styles.css +0 -1543
  139. package/electron-builder.yml +0 -140
  140. package/electron.vite.config.ts +0 -63
  141. package/railway.json +0 -12
  142. package/scripts/build.mjs +0 -51
  143. package/scripts/generate-icons.mjs +0 -314
  144. package/scripts/generate-installer-images.cjs +0 -253
  145. package/scripts/generate-tray-icons.mjs +0 -258
  146. package/scripts/notarize.cjs +0 -180
  147. package/scripts/one-click-clean-test.sh +0 -147
  148. package/scripts/postinstall.mjs +0 -36
  149. package/scripts/setup-markupr.sh +0 -55
  150. package/setup +0 -17
  151. package/site/index.html +0 -1835
  152. package/site/package.json +0 -11
  153. package/site/railway.json +0 -12
  154. package/site/server.js +0 -31
  155. package/src/main/AutoUpdater.ts +0 -392
  156. package/src/main/CrashRecovery.ts +0 -655
  157. package/src/main/ErrorHandler.ts +0 -703
  158. package/src/main/HotkeyManager.ts +0 -399
  159. package/src/main/MenuManager.ts +0 -529
  160. package/src/main/PermissionManager.ts +0 -420
  161. package/src/main/SessionController.ts +0 -1465
  162. package/src/main/TrayManager.ts +0 -540
  163. package/src/main/ai/AIPipelineManager.ts +0 -199
  164. package/src/main/ai/ClaudeAnalyzer.ts +0 -339
  165. package/src/main/ai/ImageOptimizer.ts +0 -176
  166. package/src/main/ai/StructuredMarkdownBuilder.ts +0 -379
  167. package/src/main/ai/index.ts +0 -16
  168. package/src/main/ai/types.ts +0 -258
  169. package/src/main/analysis/ClarificationGenerator.ts +0 -385
  170. package/src/main/analysis/FeedbackAnalyzer.ts +0 -531
  171. package/src/main/analysis/index.ts +0 -19
  172. package/src/main/audio/AudioCapture.ts +0 -978
  173. package/src/main/audio/audioUtils.ts +0 -100
  174. package/src/main/audio/index.ts +0 -20
  175. package/src/main/capture/index.ts +0 -1
  176. package/src/main/index.ts +0 -1693
  177. package/src/main/ipc/captureHandlers.ts +0 -272
  178. package/src/main/ipc/index.ts +0 -45
  179. package/src/main/ipc/outputHandlers.ts +0 -302
  180. package/src/main/ipc/sessionHandlers.ts +0 -56
  181. package/src/main/ipc/settingsHandlers.ts +0 -471
  182. package/src/main/ipc/types.ts +0 -56
  183. package/src/main/ipc/windowHandlers.ts +0 -277
  184. package/src/main/output/ClipboardService.ts +0 -369
  185. package/src/main/output/ExportService.ts +0 -539
  186. package/src/main/output/FileManager.ts +0 -416
  187. package/src/main/output/MarkdownGenerator.ts +0 -791
  188. package/src/main/output/MarkdownPatcher.ts +0 -299
  189. package/src/main/output/index.ts +0 -186
  190. package/src/main/output/sessionAdapter.ts +0 -207
  191. package/src/main/output/templates/html-template.ts +0 -553
  192. package/src/main/pipeline/FrameExtractor.ts +0 -330
  193. package/src/main/pipeline/PostProcessor.ts +0 -399
  194. package/src/main/pipeline/TranscriptAnalyzer.ts +0 -226
  195. package/src/main/pipeline/index.ts +0 -36
  196. package/src/main/platform/WindowsTaskbar.ts +0 -600
  197. package/src/main/platform/index.ts +0 -16
  198. package/src/main/settings/SettingsManager.ts +0 -730
  199. package/src/main/settings/index.ts +0 -19
  200. package/src/main/transcription/ModelDownloadManager.ts +0 -494
  201. package/src/main/transcription/TierManager.ts +0 -219
  202. package/src/main/transcription/TranscriptionRecoveryService.ts +0 -340
  203. package/src/main/transcription/WhisperService.ts +0 -748
  204. package/src/main/transcription/index.ts +0 -56
  205. package/src/main/transcription/types.ts +0 -135
  206. package/src/main/windows/PopoverManager.ts +0 -284
  207. package/src/main/windows/TaskbarIntegration.ts +0 -452
  208. package/src/main/windows/index.ts +0 -23
  209. package/src/preload/index.ts +0 -1047
  210. package/src/renderer/App.tsx +0 -515
  211. package/src/renderer/AppWrapper.tsx +0 -28
  212. package/src/renderer/assets/logo-dark.svg +0 -7
  213. package/src/renderer/assets/logo.svg +0 -7
  214. package/src/renderer/audio/AudioCaptureRenderer.ts +0 -454
  215. package/src/renderer/capture/ScreenRecordingRenderer.ts +0 -492
  216. package/src/renderer/components/AnnotationOverlay.tsx +0 -836
  217. package/src/renderer/components/AudioWaveform.tsx +0 -811
  218. package/src/renderer/components/ClarificationQuestions.tsx +0 -656
  219. package/src/renderer/components/CountdownTimer.tsx +0 -495
  220. package/src/renderer/components/CrashRecoveryDialog.tsx +0 -632
  221. package/src/renderer/components/DonateButton.tsx +0 -127
  222. package/src/renderer/components/ErrorBoundary.tsx +0 -308
  223. package/src/renderer/components/ExportDialog.tsx +0 -872
  224. package/src/renderer/components/HotkeyHint.tsx +0 -261
  225. package/src/renderer/components/KeyboardShortcuts.tsx +0 -787
  226. package/src/renderer/components/ModelDownloadDialog.tsx +0 -844
  227. package/src/renderer/components/Onboarding.tsx +0 -1830
  228. package/src/renderer/components/ProcessingOverlay.tsx +0 -157
  229. package/src/renderer/components/RecordingOverlay.tsx +0 -423
  230. package/src/renderer/components/SessionHistory.tsx +0 -1746
  231. package/src/renderer/components/SessionReview.tsx +0 -1321
  232. package/src/renderer/components/SettingsPanel.tsx +0 -217
  233. package/src/renderer/components/Skeleton.tsx +0 -347
  234. package/src/renderer/components/StatusIndicator.tsx +0 -86
  235. package/src/renderer/components/ThemeProvider.tsx +0 -429
  236. package/src/renderer/components/Tooltip.tsx +0 -370
  237. package/src/renderer/components/TranscriptionPreview.tsx +0 -183
  238. package/src/renderer/components/TranscriptionTierSelector.tsx +0 -640
  239. package/src/renderer/components/UpdateNotification.tsx +0 -377
  240. package/src/renderer/components/WindowSelector.tsx +0 -947
  241. package/src/renderer/components/index.ts +0 -99
  242. package/src/renderer/components/primitives/ApiKeyInput.tsx +0 -98
  243. package/src/renderer/components/primitives/ColorPicker.tsx +0 -65
  244. package/src/renderer/components/primitives/DangerButton.tsx +0 -45
  245. package/src/renderer/components/primitives/DirectoryPicker.tsx +0 -41
  246. package/src/renderer/components/primitives/Dropdown.tsx +0 -34
  247. package/src/renderer/components/primitives/KeyRecorder.tsx +0 -117
  248. package/src/renderer/components/primitives/SettingsSection.tsx +0 -32
  249. package/src/renderer/components/primitives/Slider.tsx +0 -43
  250. package/src/renderer/components/primitives/Toggle.tsx +0 -36
  251. package/src/renderer/components/primitives/index.ts +0 -10
  252. package/src/renderer/components/settings/AdvancedTab.tsx +0 -174
  253. package/src/renderer/components/settings/AppearanceTab.tsx +0 -77
  254. package/src/renderer/components/settings/GeneralTab.tsx +0 -40
  255. package/src/renderer/components/settings/HotkeysTab.tsx +0 -79
  256. package/src/renderer/components/settings/RecordingTab.tsx +0 -84
  257. package/src/renderer/components/settings/index.ts +0 -9
  258. package/src/renderer/components/settings/settingsStyles.ts +0 -673
  259. package/src/renderer/components/settings/tabConfig.tsx +0 -85
  260. package/src/renderer/components/settings/useSettingsPanel.ts +0 -447
  261. package/src/renderer/contexts/ProcessingContext.tsx +0 -227
  262. package/src/renderer/contexts/RecordingContext.tsx +0 -683
  263. package/src/renderer/contexts/UIContext.tsx +0 -326
  264. package/src/renderer/contexts/index.ts +0 -24
  265. package/src/renderer/donateMessages.ts +0 -69
  266. package/src/renderer/hooks/index.ts +0 -75
  267. package/src/renderer/hooks/useAnimation.tsx +0 -544
  268. package/src/renderer/hooks/useTheme.ts +0 -313
  269. package/src/renderer/index.html +0 -26
  270. package/src/renderer/main.tsx +0 -52
  271. package/src/renderer/styles/animations.css +0 -1093
  272. package/src/renderer/styles/app-shell.css +0 -662
  273. package/src/renderer/styles/globals.css +0 -515
  274. package/src/renderer/styles/theme.ts +0 -578
  275. package/src/renderer/types/electron.d.ts +0 -385
  276. package/src/shared/hotkeys.ts +0 -283
  277. package/src/shared/types.ts +0 -809
  278. package/tests/clipboard.test.ts +0 -228
  279. package/tests/e2e/criticalPaths.test.ts +0 -594
  280. package/tests/feedbackAnalyzer.test.ts +0 -303
  281. package/tests/integration/sessionFlow.test.ts +0 -583
  282. package/tests/markdownGenerator.test.ts +0 -418
  283. package/tests/output.test.ts +0 -96
  284. package/tests/setup.ts +0 -486
  285. package/tests/unit/appIntegration.test.ts +0 -676
  286. package/tests/unit/appViewState.test.ts +0 -281
  287. package/tests/unit/audioIpcChannels.test.ts +0 -17
  288. package/tests/unit/exportService.test.ts +0 -492
  289. package/tests/unit/hotkeys.test.ts +0 -92
  290. package/tests/unit/navigationPreload.test.ts +0 -94
  291. package/tests/unit/onboardingFlow.test.ts +0 -345
  292. package/tests/unit/permissionManager.test.ts +0 -175
  293. package/tests/unit/permissionManagerExpanded.test.ts +0 -296
  294. package/tests/unit/screenRecordingRenderer.test.ts +0 -368
  295. package/tests/unit/sessionController.test.ts +0 -515
  296. package/tests/unit/tierManager.test.ts +0 -61
  297. package/tests/unit/tierManagerExpanded.test.ts +0 -142
  298. package/tests/unit/transcriptAnalyzer.test.ts +0 -64
  299. package/tsconfig.json +0 -25
  300. package/vitest.config.ts +0 -46
@@ -1,12 +1,13 @@
1
1
  import { app, globalShortcut, dialog, shell, Notification, systemPreferences, ipcMain, safeStorage, nativeImage, Tray, Menu, clipboard, BrowserWindow, screen, desktopCapturer } from "electron";
2
2
  import * as fs from "fs/promises";
3
- import { mkdir, writeFile, unlink, readFile, stat } from "fs/promises";
3
+ import { mkdir, writeFile, unlink, chmod, readFile, stat } from "fs/promises";
4
4
  import * as path from "path";
5
- import path__default, { join, dirname, basename, extname } from "path";
5
+ import path__default, { join, dirname, basename, resolve, extname } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import Store from "electron-store";
8
8
  import { randomUUID } from "crypto";
9
9
  import { EventEmitter } from "events";
10
+ import * as fsSync from "fs";
10
11
  import { existsSync, statSync, createWriteStream, unlinkSync, mkdirSync, renameSync } from "fs";
11
12
  import * as keytar from "keytar";
12
13
  import { execFile as execFile$1 } from "child_process";
@@ -529,6 +530,143 @@ function getHotkeyManager(config) {
529
530
  return hotkeyManagerInstance;
530
531
  }
531
532
  const hotkeyManager = getHotkeyManager();
533
+ function isMacOS() {
534
+ const nodeProcess = globalThis.process;
535
+ if (nodeProcess?.platform) {
536
+ return nodeProcess.platform === "darwin";
537
+ }
538
+ if (typeof navigator !== "undefined" && navigator.platform) {
539
+ return navigator.platform.toUpperCase().includes("MAC");
540
+ }
541
+ return false;
542
+ }
543
+ const HOTKEYS = [
544
+ {
545
+ id: "toggleRecording",
546
+ label: "Toggle Recording",
547
+ description: "Start or stop recording",
548
+ macAccelerator: "Command+Shift+F",
549
+ winLinuxAccelerator: "Ctrl+Shift+F"
550
+ },
551
+ {
552
+ id: "manualScreenshot",
553
+ label: "Manual Screenshot",
554
+ description: "Capture screenshot immediately",
555
+ macAccelerator: "Command+Shift+S",
556
+ winLinuxAccelerator: "Ctrl+Shift+S"
557
+ },
558
+ {
559
+ id: "pauseResume",
560
+ label: "Pause/Resume",
561
+ description: "Pause or resume recording",
562
+ macAccelerator: "Command+Shift+P",
563
+ winLinuxAccelerator: "Ctrl+Shift+P"
564
+ },
565
+ {
566
+ id: "openSettings",
567
+ label: "Open Settings",
568
+ description: "Open settings panel",
569
+ macAccelerator: "Command+,",
570
+ winLinuxAccelerator: "Ctrl+,"
571
+ },
572
+ {
573
+ id: "showHelp",
574
+ label: "Show Help",
575
+ description: "Show keyboard shortcuts",
576
+ macAccelerator: "Command+?",
577
+ winLinuxAccelerator: "Ctrl+Shift+/"
578
+ },
579
+ {
580
+ id: "quit",
581
+ label: "Quit",
582
+ description: "Quit markupr",
583
+ macAccelerator: "Command+Q",
584
+ winLinuxAccelerator: "Alt+F4"
585
+ }
586
+ ];
587
+ const MAC_SYMBOLS = {
588
+ command: "Cmd",
589
+ cmd: "Cmd",
590
+ control: "Ctrl",
591
+ ctrl: "Ctrl",
592
+ option: "Option",
593
+ alt: "Option",
594
+ shift: "Shift",
595
+ enter: "Enter",
596
+ return: "Return",
597
+ delete: "Delete",
598
+ backspace: "Delete",
599
+ escape: "Esc",
600
+ esc: "Esc",
601
+ tab: "Tab",
602
+ space: "Space",
603
+ up: "Up",
604
+ down: "Down",
605
+ left: "Left",
606
+ right: "Right",
607
+ pageup: "Page Up",
608
+ pagedown: "Page Down",
609
+ home: "Home",
610
+ end: "End",
611
+ fn: "Fn"
612
+ };
613
+ const WIN_LINUX_NAMES = {
614
+ command: "Ctrl",
615
+ cmd: "Ctrl",
616
+ control: "Ctrl",
617
+ ctrl: "Ctrl",
618
+ option: "Alt",
619
+ alt: "Alt",
620
+ shift: "Shift",
621
+ enter: "Enter",
622
+ return: "Enter",
623
+ delete: "Del",
624
+ backspace: "Backspace",
625
+ escape: "Esc",
626
+ esc: "Esc",
627
+ tab: "Tab",
628
+ space: "Space",
629
+ up: "Up",
630
+ down: "Down",
631
+ left: "Left",
632
+ right: "Right",
633
+ pageup: "Page Up",
634
+ pagedown: "Page Down",
635
+ home: "Home",
636
+ end: "End",
637
+ fn: "Fn"
638
+ };
639
+ function getHotkeyById(id) {
640
+ return HOTKEYS.find((h) => h.id === id);
641
+ }
642
+ function getAccelerator(hotkeyId) {
643
+ const hotkey = getHotkeyById(hotkeyId);
644
+ if (!hotkey) return "";
645
+ return isMacOS() ? hotkey.macAccelerator : hotkey.winLinuxAccelerator;
646
+ }
647
+ function parseAccelerator(accelerator) {
648
+ return accelerator.split("+").map((k) => k.trim());
649
+ }
650
+ function getDisplayKeys(accelerator) {
651
+ const keys = parseAccelerator(accelerator);
652
+ const platform = isMacOS();
653
+ return keys.map((key) => {
654
+ const lowerKey = key.toLowerCase();
655
+ if (platform) {
656
+ return MAC_SYMBOLS[lowerKey] || key.toUpperCase();
657
+ } else {
658
+ return WIN_LINUX_NAMES[lowerKey] || key.toUpperCase();
659
+ }
660
+ });
661
+ }
662
+ function formatAcceleratorForDisplay(accelerator) {
663
+ const keys = getDisplayKeys(accelerator);
664
+ return keys.join("+");
665
+ }
666
+ function formatHotkeyForDisplay(hotkeyId) {
667
+ const accelerator = getAccelerator(hotkeyId);
668
+ return formatAcceleratorForDisplay(accelerator);
669
+ }
532
670
  const MAX_LOG_SIZE_BYTES = 5 * 1024 * 1024;
533
671
  const MAX_LOG_LINES = 1e4;
534
672
  const LOG_ROTATION_CHECK_INTERVAL_MS = 6e4;
@@ -539,6 +677,9 @@ class ErrorHandler {
539
677
  flushTimer = null;
540
678
  rotationTimer = null;
541
679
  isInitialized = false;
680
+ lastNotificationAt = 0;
681
+ NOTIFICATION_RATE_LIMIT_MS = 3e3;
682
+ // Min 3s between notifications
542
683
  constructor() {
543
684
  this.logPath = path.join(app.getPath("logs"), "markupr.log");
544
685
  }
@@ -554,7 +695,12 @@ class ErrorHandler {
554
695
  () => this.checkLogRotation(),
555
696
  LOG_ROTATION_CHECK_INTERVAL_MS
556
697
  );
557
- this.flushTimer = setInterval(() => this.flushLogs(), 5e3);
698
+ this.flushTimer = setInterval(() => {
699
+ try {
700
+ this.flushLogs();
701
+ } catch {
702
+ }
703
+ }, 5e3);
558
704
  this.isInitialized = true;
559
705
  this.log("info", "ErrorHandler initialized", { component: "ErrorHandler" });
560
706
  } catch (error) {
@@ -574,18 +720,25 @@ class ErrorHandler {
574
720
  * Handle permission errors and guide user to system settings
575
721
  */
576
722
  async handlePermissionError(type) {
723
+ const settingsName = process.platform === "darwin" ? "System Settings" : process.platform === "win32" ? "Windows Settings" : "system settings";
577
724
  const messages = {
578
725
  microphone: {
579
726
  title: "Microphone Access Required",
580
727
  message: "markupr needs microphone access to capture your voice feedback.",
581
- detail: 'Click "Open Settings" to grant microphone permission in System Settings.\n\nAfter enabling, you may need to restart the app.',
582
- pane: "Privacy_Microphone"
728
+ detail: `Click "Open Settings" to grant microphone permission in ${settingsName}.
729
+
730
+ After enabling, you may need to restart the app.`,
731
+ pane: "Privacy_Microphone",
732
+ winSettings: "ms-settings:privacy-microphone"
583
733
  },
584
734
  screen: {
585
735
  title: "Screen Recording Required",
586
736
  message: "markupr needs screen recording permission to capture screenshots.",
587
- detail: 'Click "Open Settings" to grant screen recording permission in System Settings.\n\nYou will need to restart the app after enabling.',
588
- pane: "Privacy_ScreenCapture"
737
+ detail: `Click "Open Settings" to grant screen recording permission in ${settingsName}.
738
+
739
+ You will need to restart the app after enabling.`,
740
+ pane: "Privacy_ScreenCapture",
741
+ winSettings: "ms-settings:privacy-screencapture"
589
742
  }
590
743
  };
591
744
  const config = messages[type];
@@ -610,7 +763,8 @@ class ErrorHandler {
610
763
  );
611
764
  this.log("info", `Opened system preferences for ${type}`);
612
765
  } else if (process.platform === "win32") {
613
- await shell.openExternal("ms-settings:privacy-microphone");
766
+ await shell.openExternal(config.winSettings);
767
+ this.log("info", `Opened Windows settings for ${type}`);
614
768
  }
615
769
  return true;
616
770
  }
@@ -829,6 +983,11 @@ Please restart the app to continue.`,
829
983
  * Show a non-blocking notification to the user
830
984
  */
831
985
  notifyUser(title, message) {
986
+ const now = Date.now();
987
+ if (now - this.lastNotificationAt < this.NOTIFICATION_RATE_LIMIT_MS) {
988
+ return;
989
+ }
990
+ this.lastNotificationAt = now;
832
991
  this.emitToRenderer(IPC_CHANNELS.NOTIFICATION, { title, message });
833
992
  if (Notification.isSupported()) {
834
993
  new Notification({
@@ -1090,6 +1249,12 @@ class AudioCaptureServiceImpl extends EventEmitter {
1090
1249
  recoveryChunks = [];
1091
1250
  recoveryInterval = null;
1092
1251
  // Full-session audio capture (used for post-session transcription + retry workflows)
1252
+ // Memory cap prevents unbounded growth during long sessions. At 16kHz mono
1253
+ // with 4 bytes/sample, a 30-minute session produces ~115MB of PCM data plus
1254
+ // encoded chunks in parallel. The cap ensures total audio memory stays under
1255
+ // control, especially on machines with limited RAM.
1256
+ static MAX_SESSION_AUDIO_BYTES = 200 * 1024 * 1024;
1257
+ // 200MB
1093
1258
  sessionAudioChunks = [];
1094
1259
  sessionAudioBytes = 0;
1095
1260
  sessionAudioDurationMs = 0;
@@ -1098,6 +1263,7 @@ class AudioCaptureServiceImpl extends EventEmitter {
1098
1263
  encodedAudioBytes = 0;
1099
1264
  encodedAudioDurationMs = 0;
1100
1265
  encodedAudioMimeType = null;
1266
+ sessionAudioCapWarningLogged = false;
1101
1267
  constructor(config = {}) {
1102
1268
  super();
1103
1269
  this.config = { ...DEFAULT_CONFIG$1, ...config };
@@ -1172,7 +1338,7 @@ class AudioCaptureServiceImpl extends EventEmitter {
1172
1338
  * This requests device list from renderer via IPC
1173
1339
  */
1174
1340
  async getDevices() {
1175
- return new Promise((resolve, reject) => {
1341
+ return new Promise((resolve2, reject) => {
1176
1342
  if (!this.mainWindow) {
1177
1343
  reject(new Error("Main window not set"));
1178
1344
  return;
@@ -1183,7 +1349,7 @@ class AudioCaptureServiceImpl extends EventEmitter {
1183
1349
  const handler = (_event, devices) => {
1184
1350
  clearTimeout(timeout);
1185
1351
  ipcMain.removeListener(AUDIO_IPC_CHANNELS.DEVICES_RESPONSE, handler);
1186
- resolve(devices);
1352
+ resolve2(devices);
1187
1353
  };
1188
1354
  ipcMain.on(AUDIO_IPC_CHANNELS.DEVICES_RESPONSE, handler);
1189
1355
  this.mainWindow.webContents.send(AUDIO_IPC_CHANNELS.REQUEST_DEVICES);
@@ -1232,7 +1398,7 @@ class AudioCaptureServiceImpl extends EventEmitter {
1232
1398
  });
1233
1399
  throw windowError;
1234
1400
  }
1235
- return new Promise((resolve, reject) => {
1401
+ return new Promise((resolve2, reject) => {
1236
1402
  const timeout = setTimeout(() => {
1237
1403
  reject(new Error("Audio capture start timeout"));
1238
1404
  }, 1e4);
@@ -1256,9 +1422,10 @@ class AudioCaptureServiceImpl extends EventEmitter {
1256
1422
  this.encodedAudioBytes = 0;
1257
1423
  this.encodedAudioDurationMs = 0;
1258
1424
  this.encodedAudioMimeType = null;
1425
+ this.sessionAudioCapWarningLogged = false;
1259
1426
  this.startRecoveryBuffer();
1260
1427
  console.log("[AudioCapture] Capture started");
1261
- resolve();
1428
+ resolve2();
1262
1429
  };
1263
1430
  const errorHandler2 = (_event, error) => {
1264
1431
  clearTimeout(timeout);
@@ -1462,8 +1629,8 @@ class AudioCaptureServiceImpl extends EventEmitter {
1462
1629
  if (this.stopPromise) {
1463
1630
  return this.stopPromise;
1464
1631
  }
1465
- this.stopPromise = new Promise((resolve) => {
1466
- this.resolveStopPromise = resolve;
1632
+ this.stopPromise = new Promise((resolve2) => {
1633
+ this.resolveStopPromise = resolve2;
1467
1634
  });
1468
1635
  return this.stopPromise;
1469
1636
  }
@@ -1500,6 +1667,7 @@ class AudioCaptureServiceImpl extends EventEmitter {
1500
1667
  this.sessionAudioBytes += buffer.byteLength;
1501
1668
  this.sessionAudioDurationMs += Math.max(0, data.duration || this.config.chunkDurationMs);
1502
1669
  this.sessionAudioMimeType = "audio/wav";
1670
+ this.enforceSessionAudioCap();
1503
1671
  this.emit("audioChunk", chunk);
1504
1672
  return;
1505
1673
  }
@@ -1512,6 +1680,7 @@ class AudioCaptureServiceImpl extends EventEmitter {
1512
1680
  this.encodedAudioDurationMs += Math.max(0, data.duration || this.config.chunkDurationMs);
1513
1681
  this.encodedAudioMimeType = data.mimeType || this.encodedAudioMimeType || "audio/webm";
1514
1682
  this.recoveryChunks.push(encodedBuffer);
1683
+ this.enforceSessionAudioCap();
1515
1684
  const level = Number.isFinite(data.audioLevel) ? Math.max(0, Math.min(1, Number(data.audioLevel))) : Math.max(0, Math.min(1, encodedBuffer.byteLength / 6e3));
1516
1685
  this.currentAudioLevel = level;
1517
1686
  this.emit("audioLevel", level);
@@ -1557,6 +1726,38 @@ class AudioCaptureServiceImpl extends EventEmitter {
1557
1726
  }
1558
1727
  }
1559
1728
  // ==========================================================================
1729
+ // Memory Management
1730
+ // ==========================================================================
1731
+ /**
1732
+ * Enforce the session audio memory cap across both PCM and encoded buffers.
1733
+ * Drops oldest chunks from whichever buffer is larger until total is under
1734
+ * 80% of the cap, preserving the most recent audio for transcription quality.
1735
+ */
1736
+ enforceSessionAudioCap() {
1737
+ const totalBytes = this.sessionAudioBytes + this.encodedAudioBytes;
1738
+ if (totalBytes <= AudioCaptureServiceImpl.MAX_SESSION_AUDIO_BYTES) {
1739
+ return;
1740
+ }
1741
+ if (!this.sessionAudioCapWarningLogged) {
1742
+ console.warn(
1743
+ `[AudioCapture] Session audio memory cap reached (${Math.round(totalBytes / 1024 / 1024)}MB). Dropping oldest chunks to stay under ${Math.round(AudioCaptureServiceImpl.MAX_SESSION_AUDIO_BYTES / 1024 / 1024)}MB.`
1744
+ );
1745
+ this.sessionAudioCapWarningLogged = true;
1746
+ }
1747
+ const targetBytes = Math.floor(AudioCaptureServiceImpl.MAX_SESSION_AUDIO_BYTES * 0.8);
1748
+ while (this.sessionAudioBytes + this.encodedAudioBytes > targetBytes && (this.sessionAudioChunks.length > 1 || this.encodedAudioChunks.length > 1)) {
1749
+ if (this.sessionAudioBytes >= this.encodedAudioBytes && this.sessionAudioChunks.length > 1) {
1750
+ const removed = this.sessionAudioChunks.shift();
1751
+ this.sessionAudioBytes -= removed.byteLength;
1752
+ } else if (this.encodedAudioChunks.length > 1) {
1753
+ const removed = this.encodedAudioChunks.shift();
1754
+ this.encodedAudioBytes -= removed.byteLength;
1755
+ } else {
1756
+ break;
1757
+ }
1758
+ }
1759
+ }
1760
+ // ==========================================================================
1560
1761
  // Recovery Buffer Management
1561
1762
  // ==========================================================================
1562
1763
  /**
@@ -1977,7 +2178,7 @@ class SettingsManager {
1977
2178
  }
1978
2179
  setFallbackApiKey(service, key) {
1979
2180
  if (!this.canUseEncryptedFallback()) {
1980
- throw new Error("safeStorage encryption is unavailable");
2181
+ throw new Error("Secure storage is unavailable. API keys cannot be saved until the app is fully initialized. Try restarting markupr.");
1981
2182
  }
1982
2183
  const encrypted = safeStorage.encryptString(key).toString("base64");
1983
2184
  this.secureStore.set(service, encrypted);
@@ -2012,6 +2213,8 @@ class SettingsManager {
2012
2213
  setInsecureApiKey(service, key) {
2013
2214
  const storeKey = this.getInsecureStoreKey(service);
2014
2215
  this.secureStore.set(storeKey, key);
2216
+ chmod(this.secureStore.path, 384).catch(() => {
2217
+ });
2015
2218
  const legacyMap = this.store.get(
2016
2219
  LEGACY_INSECURE_SECRET_STORE_KEY
2017
2220
  ) || {};
@@ -2102,7 +2305,7 @@ class SettingsManager {
2102
2305
  console.log(`[SettingsManager] Stored API key for ${service} via plaintext fallback`);
2103
2306
  } catch (insecureError) {
2104
2307
  throw new Error(
2105
- `Unable to store API key for ${service}: ${insecureError instanceof Error ? insecureError.message : String(insecureError)}`
2308
+ `Unable to store API key for ${service}. All storage methods failed. Try restarting markupr or check filesystem permissions. (${insecureError instanceof Error ? insecureError.message : String(insecureError)})`
2106
2309
  );
2107
2310
  }
2108
2311
  }
@@ -2220,13 +2423,18 @@ class SettingsManager {
2220
2423
  // IPC Handlers
2221
2424
  // --------------------------------------------------------------------------
2222
2425
  /**
2223
- * Register IPC handlers for renderer communication
2426
+ * Register IPC handlers for renderer communication.
2427
+ *
2428
+ * @deprecated Use registerSettingsHandlers() from src/main/ipc/settingsHandlers.ts instead.
2429
+ * This method is retained for interface compatibility but should not be called directly.
2430
+ * The handlers in settingsHandlers.ts include input validation and service name whitelisting.
2224
2431
  */
2225
2432
  registerIpcHandlers() {
2226
2433
  if (this.ipcRegistered) {
2227
2434
  console.warn("[SettingsManager] IPC handlers already registered");
2228
2435
  return;
2229
2436
  }
2437
+ const ALLOWED_API_SERVICES = /* @__PURE__ */ new Set(["openai", "anthropic"]);
2230
2438
  ipcMain.handle(IPC_CHANNELS.SETTINGS_GET, (_, key) => {
2231
2439
  return this.get(key);
2232
2440
  });
@@ -2238,17 +2446,21 @@ class SettingsManager {
2238
2446
  return this.get(key);
2239
2447
  });
2240
2448
  ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_API_KEY, async (_, service) => {
2449
+ if (!ALLOWED_API_SERVICES.has(service)) return null;
2241
2450
  return this.getApiKey(service);
2242
2451
  });
2243
2452
  ipcMain.handle(IPC_CHANNELS.SETTINGS_SET_API_KEY, async (_, service, key) => {
2453
+ if (!ALLOWED_API_SERVICES.has(service)) return false;
2244
2454
  await this.setApiKey(service, key);
2245
2455
  return true;
2246
2456
  });
2247
2457
  ipcMain.handle(IPC_CHANNELS.SETTINGS_DELETE_API_KEY, async (_, service) => {
2458
+ if (!ALLOWED_API_SERVICES.has(service)) return false;
2248
2459
  await this.deleteApiKey(service);
2249
2460
  return true;
2250
2461
  });
2251
2462
  ipcMain.handle(IPC_CHANNELS.SETTINGS_HAS_API_KEY, async (_, service) => {
2463
+ if (!ALLOWED_API_SERVICES.has(service)) return false;
2252
2464
  return this.hasApiKey(service);
2253
2465
  });
2254
2466
  this.ipcRegistered = true;
@@ -2327,9 +2539,10 @@ class WhisperService extends EventEmitter {
2327
2539
  */
2328
2540
  getModelsDirectory() {
2329
2541
  try {
2330
- return join(app.getPath("userData"), "whisper-models");
2542
+ const { app: app2 } = require2("electron");
2543
+ return join(app2.getPath("userData"), "whisper-models");
2331
2544
  } catch {
2332
- const homeDir = process.env.HOME || process.env.USERPROFILE || "/tmp";
2545
+ const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir();
2333
2546
  return join(homeDir, ".markupr", "whisper-models");
2334
2547
  }
2335
2548
  }
@@ -2494,12 +2707,28 @@ class WhisperService extends EventEmitter {
2494
2707
  if (!this.whisperModule) {
2495
2708
  throw new Error("Whisper module not loaded");
2496
2709
  }
2497
- const result = await this.whisperModule.whisper(samples, {
2498
- modelPath: this.config.modelPath,
2499
- language: this.config.language,
2500
- threads: this.config.threads,
2501
- translate: this.config.translateToEnglish
2710
+ const CHUNK_TIMEOUT_MS = 6e4;
2711
+ let timeoutId;
2712
+ const timeoutPromise = new Promise((_, reject) => {
2713
+ timeoutId = setTimeout(
2714
+ () => reject(new Error("Whisper transcription timed out after 60s")),
2715
+ CHUNK_TIMEOUT_MS
2716
+ );
2502
2717
  });
2718
+ let result;
2719
+ try {
2720
+ result = await Promise.race([
2721
+ this.whisperModule.whisper(samples, {
2722
+ modelPath: this.config.modelPath,
2723
+ language: this.config.language,
2724
+ threads: this.config.threads,
2725
+ translate: this.config.translateToEnglish
2726
+ }),
2727
+ timeoutPromise
2728
+ ]);
2729
+ } finally {
2730
+ if (timeoutId) clearTimeout(timeoutId);
2731
+ }
2503
2732
  if (!result || result.length === 0) {
2504
2733
  return [];
2505
2734
  }
@@ -2547,7 +2776,7 @@ class WhisperService extends EventEmitter {
2547
2776
  const percent = Math.round((i + 1) / totalChunks * 100);
2548
2777
  onProgress?.(percent);
2549
2778
  if (i < totalChunks - 1) {
2550
- await new Promise((resolve) => setTimeout(resolve, 0));
2779
+ await new Promise((resolve2) => setTimeout(resolve2, 0));
2551
2780
  }
2552
2781
  }
2553
2782
  this.log(`Transcription complete: ${results.length} segment(s)`);
@@ -2694,8 +2923,9 @@ class WhisperService extends EventEmitter {
2694
2923
  async convertWithFfmpeg(audioPath) {
2695
2924
  const ffmpegAvailable = await this.isFfmpegAvailable();
2696
2925
  if (!ffmpegAvailable) {
2926
+ const installHint = process.platform === "darwin" ? "brew install ffmpeg" : process.platform === "win32" ? "winget install ffmpeg or download from https://ffmpeg.org" : "apt install ffmpeg (Debian/Ubuntu) or dnf install ffmpeg (Fedora)";
2697
2927
  throw new Error(
2698
- "ffmpeg is not available on this system. ffmpeg is required to transcribe non-WAV audio files (webm, ogg, m4a). Install ffmpeg via: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)."
2928
+ `ffmpeg is not available on this system. ffmpeg is required to transcribe non-WAV audio files (webm, ogg, m4a). Install ffmpeg via: ${installHint}.`
2699
2929
  );
2700
2930
  }
2701
2931
  const tempFileName = `markupr-transcode-${randomUUID()}.wav`;
@@ -2715,7 +2945,11 @@ class WhisperService extends EventEmitter {
2715
2945
  "pcm_f32le",
2716
2946
  "-y",
2717
2947
  tempPath
2718
- ]);
2948
+ ], {
2949
+ env: { PATH: process.env.PATH, HOME: process.env.HOME, LANG: process.env.LANG, TMPDIR: process.env.TMPDIR }
2950
+ });
2951
+ await chmod(tempPath, 384).catch(() => {
2952
+ });
2719
2953
  this.log("ffmpeg conversion complete, parsing WAV...");
2720
2954
  return await this.parseWavFile(tempPath);
2721
2955
  } catch (error) {
@@ -2927,7 +3161,7 @@ async function recoverWithOpenAI(audioAsset, sessionStartSec, apiKey, maxAttempt
2927
3161
  );
2928
3162
  if (attempt < maxAttempts) {
2929
3163
  const delayMs = 500 * attempt;
2930
- await new Promise((resolve) => setTimeout(resolve, delayMs));
3164
+ await new Promise((resolve2) => setTimeout(resolve2, delayMs));
2931
3165
  }
2932
3166
  }
2933
3167
  }
@@ -2945,7 +3179,7 @@ async function recoverWithWhisper(audioSamples, sessionStartSec, maxAttempts) {
2945
3179
  const chunkSegments = await whisperService.transcribeSamples(chunk, chunkStartSec);
2946
3180
  recoveredSegments.push(...chunkSegments);
2947
3181
  if (offset + chunkSamples < audioSamples.length) {
2948
- await new Promise((resolve) => setTimeout(resolve, 0));
3182
+ await new Promise((resolve2) => setTimeout(resolve2, 0));
2949
3183
  }
2950
3184
  }
2951
3185
  const recoveredEvents = recoveredSegments.map((segment) => ({
@@ -2969,7 +3203,7 @@ async function recoverWithWhisper(audioSamples, sessionStartSec, maxAttempts) {
2969
3203
  );
2970
3204
  if (attempt < maxAttempts) {
2971
3205
  const delayMs = 400 * attempt;
2972
- await new Promise((resolve) => setTimeout(resolve, delayMs));
3206
+ await new Promise((resolve2) => setTimeout(resolve2, delayMs));
2973
3207
  }
2974
3208
  }
2975
3209
  }
@@ -3091,6 +3325,7 @@ class SessionController {
3091
3325
  // Watchdog state
3092
3326
  stateEnteredAt = Date.now();
3093
3327
  recordingWarningShown = false;
3328
+ recoveryInProgress = false;
3094
3329
  // Post-processing result (available after processing completes)
3095
3330
  postProcessResult = null;
3096
3331
  // Current processing progress (for status reporting)
@@ -3164,13 +3399,13 @@ class SessionController {
3164
3399
  */
3165
3400
  async withTimeoutSync(fn, timeoutMs, operationName = "operation") {
3166
3401
  return this.withTimeout(
3167
- new Promise((resolve) => {
3402
+ new Promise((resolve2) => {
3168
3403
  try {
3169
3404
  fn();
3170
- resolve();
3405
+ resolve2();
3171
3406
  } catch (error) {
3172
3407
  console.warn(`[SessionController] ${operationName} threw:`, error);
3173
- resolve();
3408
+ resolve2();
3174
3409
  }
3175
3410
  }),
3176
3411
  timeoutMs,
@@ -3184,7 +3419,6 @@ class SessionController {
3184
3419
  */
3185
3420
  async initialize() {
3186
3421
  console.log("[SessionController] Initializing...");
3187
- this.startWatchdog();
3188
3422
  console.log("[SessionController] Initialization complete");
3189
3423
  }
3190
3424
  /**
@@ -3223,6 +3457,12 @@ class SessionController {
3223
3457
  return false;
3224
3458
  }
3225
3459
  const oldState = this.state;
3460
+ if (oldState === "idle" && newState !== "idle") {
3461
+ this.startWatchdog();
3462
+ }
3463
+ if (newState === "idle") {
3464
+ this.stopWatchdog();
3465
+ }
3226
3466
  this.state = newState;
3227
3467
  this.stateEnteredAt = Date.now();
3228
3468
  this.recordingWarningShown = false;
@@ -3248,7 +3488,7 @@ class SessionController {
3248
3488
  */
3249
3489
  async start(sourceId, sourceName) {
3250
3490
  if (this.state !== "idle") {
3251
- throw new Error(`Cannot start session from state: ${this.state}`);
3491
+ throw new Error(`Cannot start a new session while in "${this.state}" state. Wait for the current session to finish or cancel it first.`);
3252
3492
  }
3253
3493
  console.log(`[SessionController] Starting session for source: ${sourceId}`);
3254
3494
  if (!this.transition("starting")) {
@@ -3358,8 +3598,10 @@ class SessionController {
3358
3598
  if (!this.transition("processing")) {
3359
3599
  console.error("[SessionController] Failed to transition to processing state");
3360
3600
  this.transitionForced("complete");
3601
+ if (this.session) this.session.state = "complete";
3602
+ } else {
3603
+ this.session.state = "processing";
3361
3604
  }
3362
- this.session.state = "processing";
3363
3605
  await this.withTimeout(
3364
3606
  this.recoverTranscriptFromCapturedAudio(),
3365
3607
  Math.floor(STATE_TIMEOUTS.processing * 0.8),
@@ -3407,8 +3649,7 @@ class SessionController {
3407
3649
  this.postProcessResult = null;
3408
3650
  this.currentProcessingProgress = null;
3409
3651
  this.resetSessionRuntimeState();
3410
- this.state = "idle";
3411
- this.emitStateChange();
3652
+ this.transitionForced("idle");
3412
3653
  }
3413
3654
  // ===========================================================================
3414
3655
  // Status & Data Access
@@ -3523,7 +3764,7 @@ class SessionController {
3523
3764
  */
3524
3765
  addFeedbackItem(item) {
3525
3766
  if (!this.session) {
3526
- throw new Error("No active session");
3767
+ throw new Error("No active session. Start a recording before adding feedback items.");
3527
3768
  }
3528
3769
  const feedbackItem = {
3529
3770
  id: randomUUID(),
@@ -3740,16 +3981,20 @@ class SessionController {
3740
3981
  */
3741
3982
  persistSession() {
3742
3983
  if (this.session) {
3743
- const persisted = {
3744
- id: this.session.id,
3745
- startTime: this.session.startTime,
3746
- endTime: this.session.endTime,
3747
- state: this.session.state,
3748
- sourceId: this.session.sourceId,
3749
- feedbackItemCount: this.session.feedbackItems.length,
3750
- metadata: this.session.metadata
3751
- };
3752
- store$1.set("currentSession", persisted);
3984
+ try {
3985
+ const persisted = {
3986
+ id: this.session.id,
3987
+ startTime: this.session.startTime,
3988
+ endTime: this.session.endTime,
3989
+ state: this.session.state,
3990
+ sourceId: this.session.sourceId,
3991
+ feedbackItemCount: this.session.feedbackItems.length,
3992
+ metadata: this.session.metadata
3993
+ };
3994
+ store$1.set("currentSession", persisted);
3995
+ } catch (err) {
3996
+ console.error("[SessionController] Failed to persist session:", err);
3997
+ }
3753
3998
  }
3754
3999
  }
3755
4000
  // ===========================================================================
@@ -3827,12 +4072,15 @@ class SessionController {
3827
4072
  * Called by watchdog when a state exceeds its timeout.
3828
4073
  */
3829
4074
  forceRecovery() {
4075
+ if (this.recoveryInProgress) return;
4076
+ this.recoveryInProgress = true;
3830
4077
  console.log(`[SessionController] Force recovery from state: ${this.state}`);
3831
4078
  switch (this.state) {
3832
4079
  case "starting":
3833
4080
  this.handleTimeoutError("Service initialization timed out");
3834
4081
  this.cleanupServicesForced();
3835
4082
  this.transitionForced("idle");
4083
+ this.recoveryInProgress = false;
3836
4084
  break;
3837
4085
  case "recording":
3838
4086
  this.stop().catch((error) => {
@@ -3840,6 +4088,8 @@ class SessionController {
3840
4088
  this.handleTimeoutError("Recording auto-stop failed");
3841
4089
  this.cleanupServicesForced();
3842
4090
  this.transitionForced("error");
4091
+ }).finally(() => {
4092
+ this.recoveryInProgress = false;
3843
4093
  });
3844
4094
  break;
3845
4095
  case "stopping":
@@ -3847,6 +4097,7 @@ class SessionController {
3847
4097
  this.cleanupServicesForced();
3848
4098
  this.transitionForced("processing");
3849
4099
  this.stateEnteredAt = Date.now();
4100
+ this.recoveryInProgress = false;
3850
4101
  break;
3851
4102
  case "processing":
3852
4103
  console.warn("[SessionController] Processing timeout, completing with partial data");
@@ -3858,19 +4109,23 @@ class SessionController {
3858
4109
  }
3859
4110
  this.transitionForced("complete");
3860
4111
  this.stateEnteredAt = Date.now();
4112
+ this.recoveryInProgress = false;
3861
4113
  break;
3862
4114
  case "complete":
3863
4115
  console.log("[SessionController] Complete timeout, resetting to idle");
3864
4116
  this.session = null;
3865
4117
  this.transitionForced("idle");
4118
+ this.recoveryInProgress = false;
3866
4119
  break;
3867
4120
  case "error":
3868
4121
  console.log("[SessionController] Error timeout, resetting to idle");
3869
4122
  this.session = null;
3870
4123
  this.transitionForced("idle");
4124
+ this.recoveryInProgress = false;
3871
4125
  break;
3872
4126
  case "idle":
3873
4127
  console.warn("[SessionController] Unexpected watchdog trigger in idle state");
4128
+ this.recoveryInProgress = false;
3874
4129
  break;
3875
4130
  }
3876
4131
  }
@@ -3880,6 +4135,9 @@ class SessionController {
3880
4135
  */
3881
4136
  transitionForced(newState) {
3882
4137
  const oldState = this.state;
4138
+ if (newState === "idle") {
4139
+ this.stopWatchdog();
4140
+ }
3883
4141
  this.state = newState;
3884
4142
  this.stateEnteredAt = Date.now();
3885
4143
  if (this.session) {
@@ -4079,13 +4337,16 @@ class SessionController {
4079
4337
  }
4080
4338
  }
4081
4339
  const sessionController = new SessionController();
4082
- const STATE_TOOLTIPS = {
4083
- idle: "markupr - Ready (Cmd+Shift+F)",
4084
- recording: "markupr - Recording... (Cmd+Shift+F to stop)",
4085
- processing: "markupr - Processing...",
4086
- complete: "markupr - Feedback captured!",
4087
- error: "markupr - Error (click for details)"
4088
- };
4340
+ function buildStateTooltips() {
4341
+ const toggleKey = formatHotkeyForDisplay("toggleRecording");
4342
+ return {
4343
+ idle: `markupr - Ready (${toggleKey})`,
4344
+ recording: `markupr - Recording... (${toggleKey} to stop)`,
4345
+ processing: "markupr - Processing...",
4346
+ complete: "markupr - Feedback captured!",
4347
+ error: "markupr - Error (click for details)"
4348
+ };
4349
+ }
4089
4350
  const DONATE_URL = "https://ko-fi.com/eddiesanjuan";
4090
4351
  class TrayManagerImpl {
4091
4352
  tray = null;
@@ -4213,7 +4474,7 @@ class TrayManagerImpl {
4213
4474
  }
4214
4475
  const icon = this.loadIcon("idle");
4215
4476
  this.tray = new Tray(icon);
4216
- this.tray.setToolTip(STATE_TOOLTIPS.idle);
4477
+ this.tray.setToolTip(buildStateTooltips().idle);
4217
4478
  this.updateContextMenu();
4218
4479
  if (process.platform === "darwin") {
4219
4480
  this.tray.on("mouse-up", (event) => {
@@ -4298,7 +4559,7 @@ class TrayManagerImpl {
4298
4559
  }
4299
4560
  const icon = this.loadIcon(state);
4300
4561
  this.tray.setImage(icon);
4301
- this.tray.setToolTip(STATE_TOOLTIPS[state]);
4562
+ this.tray.setToolTip(buildStateTooltips()[state]);
4302
4563
  if (state === "processing") {
4303
4564
  this.startProcessingAnimation();
4304
4565
  } else if (state === "recording" && process.platform !== "darwin") {
@@ -5012,6 +5273,7 @@ ${REPORT_SUPPORT_LINE}
5012
5273
  const screenshotCount = this.countScreenshots(items);
5013
5274
  const topThemes = this.extractTopThemes(items);
5014
5275
  const highImpactCount = (severityCounts.Critical || 0) + (severityCounts.High || 0);
5276
+ const platform = session.metadata?.os || process?.platform || "Unknown";
5015
5277
  let content = `# ${projectName} Feedback Report
5016
5278
  > Generated by markupr on ${timestamp}
5017
5279
  > Duration: ${duration} | Items: ${items.length} | Screenshots: ${screenshotCount}
@@ -5019,7 +5281,8 @@ ${REPORT_SUPPORT_LINE}
5019
5281
  ## Session Overview
5020
5282
  - **Session ID:** \`${session.id}\`
5021
5283
  - **Source:** ${session.metadata?.sourceName || "Unknown"} (${session.metadata?.sourceType || "screen"})
5022
- - **Environment:** ${session.metadata?.os || "Unknown"}
5284
+ - **Platform:** ${platform}
5285
+ - **Segments:** ${items.length}
5023
5286
  - **High-impact items:** ${highImpactCount}
5024
5287
 
5025
5288
  ---
@@ -5138,15 +5401,13 @@ ${REPORT_SUPPORT_LINE}
5138
5401
  */
5139
5402
  generateFromPostProcess(result, sessionDir) {
5140
5403
  const { transcriptSegments, extractedFrames } = result;
5141
- const now = /* @__PURE__ */ new Date();
5142
- const sessionTimestamp = now.toLocaleString("en-US", {
5143
- year: "numeric",
5144
- month: "short",
5145
- day: "numeric",
5146
- hour: "2-digit",
5147
- minute: "2-digit"
5148
- });
5404
+ const sessionTimestamp = this.formatDateDeterministic(/* @__PURE__ */ new Date());
5405
+ const sessionDuration = transcriptSegments.length > 0 ? this.formatDuration(
5406
+ (transcriptSegments[transcriptSegments.length - 1].endTime - transcriptSegments[0].startTime) * 1e3
5407
+ ) : "0:00";
5149
5408
  let md = `# markupr Session — ${sessionTimestamp}
5409
+ `;
5410
+ md += `> Segments: ${transcriptSegments.length} | Frames: ${extractedFrames.length} | Duration: ${sessionDuration}
5150
5411
 
5151
5412
  `;
5152
5413
  if (transcriptSegments.length === 0) {
@@ -5247,8 +5508,14 @@ ${REPORT_SUPPORT_LINE}
5247
5508
  /**
5248
5509
  * Generate a clipboard-friendly summary (<1500 chars).
5249
5510
  * Includes priority items and a reference to the full report.
5511
+ *
5512
+ * @param session - Session data
5513
+ * @param projectName - Optional project name for the header
5514
+ * @param reportPath - Optional absolute or relative path to the full report file.
5515
+ * When provided, the summary links to this path instead of the
5516
+ * generic ./feedback-report.md placeholder.
5250
5517
  */
5251
- generateClipboardSummary(session, projectName) {
5518
+ generateClipboardSummary(session, projectName, reportPath) {
5252
5519
  const name = projectName || session.metadata?.sourceName || "Project";
5253
5520
  const items = session.feedbackItems;
5254
5521
  let summary = `# Feedback: ${name} - ${items.length} items
@@ -5272,7 +5539,7 @@ ${REPORT_SUPPORT_LINE}
5272
5539
  `;
5273
5540
  }
5274
5541
  summary += `
5275
- **Full report:** ./feedback-report.md`;
5542
+ **Full report:** ${reportPath || "./feedback-report.md"}`;
5276
5543
  if (summary.length > 1500) {
5277
5544
  summary = summary.slice(0, 1497) + "...";
5278
5545
  }
@@ -5303,19 +5570,18 @@ ${REPORT_SUPPORT_LINE}
5303
5570
  return text.slice(0, maxLength - 3) + "...";
5304
5571
  }
5305
5572
  /**
5306
- * Wrap transcription for markdown blockquote (handle multi-line)
5573
+ * Wrap transcription for markdown blockquote (handle multi-line).
5574
+ * Splits on sentence-ending punctuation followed by whitespace so that
5575
+ * all multi-sentence inputs (including 2-sentence ones) get proper
5576
+ * blockquote continuation lines.
5307
5577
  */
5308
5578
  wrapTranscription(transcription) {
5309
- const sentences = transcription.match(/[^.!?]+[.!?]+/g);
5310
- if (!sentences) return transcription;
5311
- const lastMatch = sentences[sentences.length - 1];
5312
- const lastMatchEnd = transcription.lastIndexOf(lastMatch) + lastMatch.length;
5313
- const remainder = transcription.slice(lastMatchEnd).trim();
5314
- if (remainder) {
5315
- sentences.push(remainder);
5579
+ if (!transcription.includes(".") && !transcription.includes("!") && !transcription.includes("?")) {
5580
+ return transcription;
5316
5581
  }
5317
- if (sentences.length <= 2) return transcription;
5318
- return sentences.map((s) => s.trim()).join("\n> ");
5582
+ const sentences = transcription.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
5583
+ if (sentences.length <= 1) return transcription;
5584
+ return sentences.join("\n> ");
5319
5585
  }
5320
5586
  /**
5321
5587
  * Format duration from milliseconds to M:SS
@@ -5327,16 +5593,27 @@ ${REPORT_SUPPORT_LINE}
5327
5593
  return `${mins}:${secs.toString().padStart(2, "0")}`;
5328
5594
  }
5329
5595
  /**
5330
- * Format timestamp to locale string
5596
+ * Format timestamp to a deterministic human-readable string.
5597
+ * Uses explicit formatting instead of toLocaleString to produce
5598
+ * consistent output across platforms and Node.js versions.
5331
5599
  */
5332
5600
  formatTimestamp(ms) {
5333
- return new Date(ms).toLocaleString("en-US", {
5334
- year: "numeric",
5335
- month: "short",
5336
- day: "numeric",
5337
- hour: "2-digit",
5338
- minute: "2-digit"
5339
- });
5601
+ return this.formatDateDeterministic(new Date(ms));
5602
+ }
5603
+ /**
5604
+ * Produce a deterministic date string: "Feb 14, 2026 at 10:30 AM".
5605
+ * Avoids toLocaleString which can vary across OS versions.
5606
+ */
5607
+ formatDateDeterministic(date) {
5608
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
5609
+ const month = months[date.getMonth()];
5610
+ const day = date.getDate();
5611
+ const year = date.getFullYear();
5612
+ const rawHours = date.getHours();
5613
+ const ampm = rawHours >= 12 ? "PM" : "AM";
5614
+ const hours = rawHours % 12 || 12;
5615
+ const minutes = date.getMinutes().toString().padStart(2, "0");
5616
+ return `${month} ${day}, ${year} at ${hours}:${minutes} ${ampm}`;
5340
5617
  }
5341
5618
  /**
5342
5619
  * Format item timestamp as MM:SS from session start
@@ -6153,6 +6430,7 @@ Your job is to transform this raw narration into a structured, actionable feedba
6153
6430
  6. **Categorize.** Use exactly one of: Bug, UX Issue, Performance, Suggestion, Question, Positive Note.
6154
6431
  7. **Write a summary.** 2-3 sentences capturing the most important findings.
6155
6432
  8. **Be concise.** Developers will paste this into AI coding tools. Every word must earn its place.
6433
+ 9. **Handle sparse input.** If the transcript is very short or absent, focus on describing what you see in screenshots. If neither transcript nor screenshots are available, return a minimal result with an empty items array.
6156
6434
 
6157
6435
  ## Output Format
6158
6436
 
@@ -6261,7 +6539,12 @@ class ClaudeAnalyzer {
6261
6539
  */
6262
6540
  async analyze(session, imageOptions) {
6263
6541
  try {
6542
+ const transcriptText = buildTranscriptText(session);
6264
6543
  const optimizedImages = optimizeForAPI(session.screenshotBuffer, imageOptions);
6544
+ if (transcriptText === "[No transcript available]" && optimizedImages.length === 0) {
6545
+ console.log("[ClaudeAnalyzer] Skipping API call: no transcript and no screenshots");
6546
+ return null;
6547
+ }
6265
6548
  const userContent = buildUserContent(session, optimizedImages);
6266
6549
  let timeoutHandle;
6267
6550
  const timeoutPromise = new Promise((_, reject) => {
@@ -6429,7 +6712,7 @@ class StructuredMarkdownBuilder {
6429
6712
  buildSingleItem(item, index, session, options) {
6430
6713
  const timestamp = this.estimateItemTimestamp(item, session);
6431
6714
  const lines = [];
6432
- lines.push(`### ${index + 1}. ${item.title}`);
6715
+ lines.push(`### ${this.formatItemId(index)}: ${item.title}`);
6433
6716
  lines.push(`> "${item.quote}"`);
6434
6717
  lines.push("");
6435
6718
  for (const ssIndex of item.screenshotIndices) {
@@ -6509,13 +6792,16 @@ class StructuredMarkdownBuilder {
6509
6792
  const duration = session.endTime ? this.formatDuration(session.endTime - session.startTime) : "In Progress";
6510
6793
  const screenshotCount = session.screenshotBuffer.length;
6511
6794
  const modelLabel = options.modelId ?? "Claude";
6795
+ const platform = process?.platform ?? "Unknown";
6512
6796
  const lines = [
6513
6797
  "## Session Info",
6514
6798
  "",
6515
6799
  `- **Session ID:** \`${session.id}\``,
6516
6800
  `- **Source:** ${session.metadata.sourceName ?? "Unknown"} (${session.metadata.windowTitle ? "window" : "screen"})`,
6801
+ `- **Platform:** ${platform}`,
6517
6802
  `- **Duration:** ${duration}`,
6518
- `- **Screenshots:** ${screenshotCount}`
6803
+ `- **Screenshots:** ${screenshotCount}`,
6804
+ `- **Segments:** ${session.transcriptBuffer.length}`
6519
6805
  ];
6520
6806
  if (options.hasRecording) {
6521
6807
  const filename = options.recordingFilename ?? "session-recording.webm";
@@ -6568,7 +6854,8 @@ class StructuredMarkdownBuilder {
6568
6854
  (seg) => seg.text.includes(quotePrefix)
6569
6855
  );
6570
6856
  if (matchingSegment) {
6571
- const relativeMs = matchingSegment.timestamp * 1e3 - session.startTime;
6857
+ const tsMs = Math.round(matchingSegment.timestamp * 1e3);
6858
+ const relativeMs = tsMs - session.startTime;
6572
6859
  return this.formatTimestamp(relativeMs);
6573
6860
  }
6574
6861
  if (item.screenshotIndices.length > 0) {
@@ -6868,7 +7155,7 @@ class ModelDownloadManager extends EventEmitter {
6868
7155
  throw new Error(`Download already in progress for ${model}`);
6869
7156
  }
6870
7157
  this.log(`Starting download: ${model} (${info.sizeMB}MB)`);
6871
- return new Promise((resolve, reject) => {
7158
+ return new Promise((resolve2, reject) => {
6872
7159
  let downloadedBytes = 0;
6873
7160
  let lastProgressTime = Date.now();
6874
7161
  let lastDownloadedBytes = 0;
@@ -6979,7 +7266,7 @@ class ModelDownloadManager extends EventEmitter {
6979
7266
  this.completeCallbacks.forEach((cb) => cb(result));
6980
7267
  this.emit("complete", result);
6981
7268
  this.log(`Download complete: ${model}`);
6982
- resolve(result);
7269
+ resolve2(result);
6983
7270
  } else {
6984
7271
  const error = new Error("Downloaded file size mismatch - download may be corrupted");
6985
7272
  this.handleDownloadError(error, model, targetPath);
@@ -7489,7 +7776,7 @@ class CrashRecoveryManager {
7489
7776
  error: error.message,
7490
7777
  stack: error.stack
7491
7778
  });
7492
- this.logCrash(error, { type });
7779
+ this.logCrashSync(error, { type });
7493
7780
  if (this.currentSession) {
7494
7781
  this.currentSession.lastSaveTime = Date.now();
7495
7782
  store.set("activeSession", this.currentSession);
@@ -7571,17 +7858,21 @@ class CrashRecoveryManager {
7571
7858
  this.stopAutoSave();
7572
7859
  this.saveInterval = setInterval(() => {
7573
7860
  if (this.currentSession) {
7574
- this.currentSession.lastSaveTime = Date.now();
7575
- this.currentSession.metadata.sessionDurationMs = Date.now() - this.currentSession.startTime;
7576
- store.set("activeSession", this.currentSession);
7577
- errorHandler.log("debug", "Auto-saved session state", {
7578
- component: "CrashRecovery",
7579
- operation: "autoSave",
7580
- data: {
7581
- sessionId: this.currentSession.id,
7582
- feedbackCount: this.currentSession.feedbackItems.length
7583
- }
7584
- });
7861
+ try {
7862
+ this.currentSession.lastSaveTime = Date.now();
7863
+ this.currentSession.metadata.sessionDurationMs = Date.now() - this.currentSession.startTime;
7864
+ store.set("activeSession", this.currentSession);
7865
+ errorHandler.log("debug", "Auto-saved session state", {
7866
+ component: "CrashRecovery",
7867
+ operation: "autoSave",
7868
+ data: {
7869
+ sessionId: this.currentSession.id,
7870
+ feedbackCount: this.currentSession.feedbackItems.length
7871
+ }
7872
+ });
7873
+ } catch (err) {
7874
+ console.error("[CrashRecovery] Auto-save failed:", err);
7875
+ }
7585
7876
  }
7586
7877
  }, intervalMs);
7587
7878
  }
@@ -7664,6 +7955,53 @@ class CrashRecoveryManager {
7664
7955
  store.set("crashLogs", logs);
7665
7956
  await this.writeCrashLogToFile(crashLog);
7666
7957
  }
7958
+ /**
7959
+ * Synchronous crash logging for use in uncaughtException handlers.
7960
+ * Uses writeFileSync to ensure data is written before process exits.
7961
+ */
7962
+ logCrashSync(error, context) {
7963
+ const crashLog = {
7964
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7965
+ error: {
7966
+ name: error.name,
7967
+ message: error.message,
7968
+ stack: error.stack
7969
+ },
7970
+ appVersion: app.getVersion(),
7971
+ platform: process.platform,
7972
+ arch: process.arch,
7973
+ sessionId: this.currentSession?.id,
7974
+ context
7975
+ };
7976
+ try {
7977
+ const settings = this.getSettings();
7978
+ const logs = store.get("crashLogs") || [];
7979
+ logs.push(crashLog);
7980
+ while (logs.length > settings.maxCrashLogs) {
7981
+ logs.shift();
7982
+ }
7983
+ store.set("crashLogs", logs);
7984
+ } catch {
7985
+ }
7986
+ try {
7987
+ const logDir = path.dirname(this.crashLogPath);
7988
+ if (!fsSync.existsSync(logDir)) {
7989
+ fsSync.mkdirSync(logDir, { recursive: true });
7990
+ }
7991
+ let logs = [];
7992
+ try {
7993
+ const existing = fsSync.readFileSync(this.crashLogPath, "utf-8");
7994
+ logs = JSON.parse(existing);
7995
+ } catch {
7996
+ }
7997
+ logs.push(crashLog);
7998
+ while (logs.length > 50) {
7999
+ logs.shift();
8000
+ }
8001
+ fsSync.writeFileSync(this.crashLogPath, JSON.stringify(logs, null, 2));
8002
+ } catch {
8003
+ }
8004
+ }
7667
8005
  /**
7668
8006
  * Write crash log to JSON file
7669
8007
  */
@@ -7819,6 +8157,7 @@ const PAUSE_THRESHOLD_SECONDS = 1.5;
7819
8157
  const PERIODIC_INTERVAL_SECONDS = 15;
7820
8158
  const MAX_PERIODIC_INTERVAL_SECONDS = 20;
7821
8159
  const MAX_KEY_MOMENTS = 20;
8160
+ const FRAME_EDGE_MARGIN_SECONDS$1 = 0.35;
7822
8161
  class TranscriptAnalyzer {
7823
8162
  /**
7824
8163
  * Analyze transcript segments and return key moments where frames
@@ -7834,8 +8173,11 @@ class TranscriptAnalyzer {
7834
8173
  }
7835
8174
  const moments = [];
7836
8175
  const firstSegment = segments[0];
8176
+ const lastSegment = segments[segments.length - 1];
8177
+ const sessionDuration = lastSegment.endTime - firstSegment.startTime;
8178
+ const startOffset = sessionDuration > FRAME_EDGE_MARGIN_SECONDS$1 ? FRAME_EDGE_MARGIN_SECONDS$1 : 0;
7837
8179
  moments.push({
7838
- timestamp: firstSegment.startTime,
8180
+ timestamp: firstSegment.startTime + startOffset,
7839
8181
  reason: "Session start",
7840
8182
  confidence: 1
7841
8183
  });
@@ -7852,8 +8194,7 @@ class TranscriptAnalyzer {
7852
8194
  });
7853
8195
  }
7854
8196
  }
7855
- const lastSegment = segments[segments.length - 1];
7856
- if (lastSegment.endTime !== firstSegment.startTime) {
8197
+ if (lastSegment.endTime > firstSegment.startTime + startOffset) {
7857
8198
  moments.push({
7858
8199
  timestamp: lastSegment.endTime,
7859
8200
  reason: "Session end",
@@ -7861,16 +8202,13 @@ class TranscriptAnalyzer {
7861
8202
  });
7862
8203
  }
7863
8204
  if (moments.length < 3 && aiHints.length === 0) {
7864
- const sessionStart = firstSegment.startTime;
7865
- const sessionEnd = lastSegment.endTime;
7866
- const sessionDuration = sessionEnd - sessionStart;
7867
8205
  if (sessionDuration > PERIODIC_INTERVAL_SECONDS) {
7868
8206
  const rawCount = Math.floor(sessionDuration / PERIODIC_INTERVAL_SECONDS);
7869
8207
  const interval = Math.min(
7870
8208
  sessionDuration / rawCount,
7871
8209
  MAX_PERIODIC_INTERVAL_SECONDS
7872
8210
  );
7873
- for (let t = sessionStart + interval; t < sessionEnd; t += interval) {
8211
+ for (let t = firstSegment.startTime + interval; t < lastSegment.endTime; t += interval) {
7874
8212
  moments.push({
7875
8213
  timestamp: t,
7876
8214
  reason: "Periodic capture",
@@ -7957,6 +8295,14 @@ const FFMPEG_FAST_FRAME_TIMEOUT_MS = 1e4;
7957
8295
  const FFMPEG_CHECK_TIMEOUT_MS = 5e3;
7958
8296
  const FRAME_EDGE_MARGIN_SECONDS = 0.35;
7959
8297
  const TIMESTAMP_DEDUPE_WINDOW_SECONDS = 0.15;
8298
+ const SAFE_CHILD_ENV = {
8299
+ PATH: process.env.PATH,
8300
+ HOME: process.env.HOME || process.env.USERPROFILE,
8301
+ USERPROFILE: process.env.USERPROFILE,
8302
+ LANG: process.env.LANG,
8303
+ TMPDIR: process.env.TMPDIR || process.env.TEMP,
8304
+ TEMP: process.env.TEMP
8305
+ };
7960
8306
  class FrameExtractor {
7961
8307
  ffmpegPath = "ffmpeg";
7962
8308
  ffprobePath = "ffprobe";
@@ -7972,7 +8318,8 @@ class FrameExtractor {
7972
8318
  }
7973
8319
  try {
7974
8320
  await execFile(this.ffmpegPath, ["-version"], {
7975
- timeout: FFMPEG_CHECK_TIMEOUT_MS
8321
+ timeout: FFMPEG_CHECK_TIMEOUT_MS,
8322
+ env: SAFE_CHILD_ENV
7976
8323
  });
7977
8324
  this.ffmpegAvailable = true;
7978
8325
  this.log("ffmpeg is available");
@@ -8017,7 +8364,7 @@ class FrameExtractor {
8017
8364
  await this.extractSingleFrame(request.videoPath, timestamp, outputPath);
8018
8365
  const stats = await stat(outputPath).catch(() => null);
8019
8366
  if (!stats || stats.size <= 0) {
8020
- throw new Error("ffmpeg did not produce a frame file");
8367
+ throw new Error(`ffmpeg did not produce a frame file at timestamp ${timestamp.toFixed(1)}s. The video may be shorter than expected or the codec may not support seeking.`);
8021
8368
  }
8022
8369
  frames.push({
8023
8370
  path: outputPath,
@@ -8070,7 +8417,8 @@ class FrameExtractor {
8070
8417
  outputPath
8071
8418
  ];
8072
8419
  await execFile(this.ffmpegPath, args, {
8073
- timeout: FFMPEG_ACCURATE_FRAME_TIMEOUT_MS
8420
+ timeout: FFMPEG_ACCURATE_FRAME_TIMEOUT_MS,
8421
+ env: SAFE_CHILD_ENV
8074
8422
  });
8075
8423
  }
8076
8424
  async extractSingleFrameFast(videoPath, timestamp, outputPath) {
@@ -8090,7 +8438,8 @@ class FrameExtractor {
8090
8438
  outputPath
8091
8439
  ];
8092
8440
  await execFile(this.ffmpegPath, args, {
8093
- timeout: FFMPEG_FAST_FRAME_TIMEOUT_MS
8441
+ timeout: FFMPEG_FAST_FRAME_TIMEOUT_MS,
8442
+ env: SAFE_CHILD_ENV
8094
8443
  });
8095
8444
  }
8096
8445
  /**
@@ -8129,7 +8478,7 @@ class FrameExtractor {
8129
8478
  "default=noprint_wrappers=1:nokey=1",
8130
8479
  videoPath
8131
8480
  ],
8132
- { timeout: FFMPEG_CHECK_TIMEOUT_MS }
8481
+ { timeout: FFMPEG_CHECK_TIMEOUT_MS, env: SAFE_CHILD_ENV }
8133
8482
  );
8134
8483
  const parsed = Number.parseFloat(String(stdout).trim());
8135
8484
  if (Number.isFinite(parsed) && parsed > 0) {
@@ -9326,7 +9675,7 @@ class PopoverManager {
9326
9675
  preload: preloadPath,
9327
9676
  nodeIntegration: false,
9328
9677
  contextIsolation: true,
9329
- sandbox: false
9678
+ sandbox: true
9330
9679
  }
9331
9680
  });
9332
9681
  this.window.on("blur", () => {
@@ -9345,7 +9694,7 @@ class PopoverManager {
9345
9694
  * Show the popover anchored to the tray icon
9346
9695
  */
9347
9696
  show() {
9348
- if (!this.window || !this.tray) return;
9697
+ if (!this.window || this.window.isDestroyed() || !this.tray) return;
9349
9698
  const position = this.calculatePosition();
9350
9699
  this.window.setPosition(position.x, position.y, false);
9351
9700
  this.window.show();
@@ -9356,7 +9705,7 @@ class PopoverManager {
9356
9705
  * Hide the popover
9357
9706
  */
9358
9707
  hide() {
9359
- if (!this.window) return;
9708
+ if (!this.window || this.window.isDestroyed()) return;
9360
9709
  this.window.hide();
9361
9710
  console.log("[PopoverManager] Popover hidden");
9362
9711
  }
@@ -9364,7 +9713,7 @@ class PopoverManager {
9364
9713
  * Toggle popover visibility
9365
9714
  */
9366
9715
  toggle() {
9367
- if (!this.window) return;
9716
+ if (!this.window || this.window.isDestroyed()) return;
9368
9717
  if (this.window.isVisible()) {
9369
9718
  this.hide();
9370
9719
  } else {
@@ -9376,7 +9725,7 @@ class PopoverManager {
9376
9725
  * Handles multi-monitor setups and taskbar positions
9377
9726
  */
9378
9727
  calculatePosition() {
9379
- if (!this.tray || !this.window) {
9728
+ if (!this.tray || !this.window || this.window.isDestroyed()) {
9380
9729
  return { x: 0, y: 0 };
9381
9730
  }
9382
9731
  const trayBounds = this.tray.getBounds();
@@ -9417,14 +9766,14 @@ class PopoverManager {
9417
9766
  * Check if popover is visible
9418
9767
  */
9419
9768
  isVisible() {
9420
- return this.window?.isVisible() ?? false;
9769
+ return this.window && !this.window.isDestroyed() ? this.window.isVisible() : false;
9421
9770
  }
9422
9771
  /**
9423
9772
  * Resize the popover for different states
9424
9773
  * Re-anchors to tray if visible
9425
9774
  */
9426
9775
  resize(width, height) {
9427
- if (!this.window) return;
9776
+ if (!this.window || this.window.isDestroyed()) return;
9428
9777
  this.window.setSize(width, height, true);
9429
9778
  if (this.window.isVisible()) {
9430
9779
  const position = this.calculatePosition();
@@ -9466,7 +9815,7 @@ class PopoverManager {
9466
9815
  console.log("[PopoverManager] Destroyed");
9467
9816
  }
9468
9817
  applyStateAppearance(state) {
9469
- if (!this.window || process.platform !== "darwin") {
9818
+ if (!this.window || this.window.isDestroyed() || process.platform !== "darwin") {
9470
9819
  return;
9471
9820
  }
9472
9821
  const isHudState = state === "recording" || state === "processing";
@@ -10041,13 +10390,14 @@ class PermissionManager {
10041
10390
  // ==========================================================================
10042
10391
  /**
10043
10392
  * Show a helpful dialog when permission is denied
10044
- * Offers to open System Preferences directly
10393
+ * Offers to open system settings directly
10045
10394
  */
10046
10395
  async showPermissionDeniedDialog(type) {
10047
10396
  const config = PERMISSION_DESCRIPTIONS[type];
10397
+ const settingsLabel = process.platform === "darwin" ? "Open System Settings" : process.platform === "win32" ? "Open Windows Settings" : "Open Settings";
10048
10398
  const options = {
10049
10399
  type: "warning",
10050
- buttons: ["Open System Settings", "Later"],
10400
+ buttons: [settingsLabel, "Later"],
10051
10401
  defaultId: 0,
10052
10402
  cancelId: 1,
10053
10403
  title: `${config.title} Required`,
@@ -10055,7 +10405,7 @@ class PermissionManager {
10055
10405
  detail: `${config.description}
10056
10406
 
10057
10407
  To enable this permission:
10058
- 1. Click "Open System Settings"
10408
+ 1. Click "${settingsLabel}"
10059
10409
  2. Find markupr in the list
10060
10410
  3. Toggle it ON
10061
10411
  4. You may need to restart markupr`
@@ -10123,11 +10473,12 @@ Would you like to set up permissions now?`
10123
10473
  * Get user-friendly description of permission state
10124
10474
  */
10125
10475
  getPermissionStateDescription(type, state) {
10476
+ const settingsName = process.platform === "darwin" ? "System Settings" : process.platform === "win32" ? "Windows Settings" : "system settings";
10126
10477
  switch (state) {
10127
10478
  case "granted":
10128
10479
  return "Enabled";
10129
10480
  case "denied":
10130
- return "Denied - click to enable in System Settings";
10481
+ return `Denied - click to enable in ${settingsName}`;
10131
10482
  case "not-determined":
10132
10483
  return "Not set - click to enable";
10133
10484
  case "restricted":
@@ -10140,43 +10491,88 @@ Would you like to set up permissions now?`
10140
10491
  const permissionManager = new PermissionManager();
10141
10492
  function registerSessionHandlers(ctx, actions) {
10142
10493
  ipcMain.handle(IPC_CHANNELS.SESSION_START, async (_, sourceId, sourceName) => {
10143
- console.log("[Main] Starting session");
10144
- return actions.startSession(sourceId, sourceName);
10494
+ try {
10495
+ console.log("[Main] Starting session");
10496
+ return await actions.startSession(sourceId, sourceName);
10497
+ } catch (error) {
10498
+ console.error("[IPC] SESSION_START failed:", error);
10499
+ return { success: false, error: error instanceof Error ? error.message : "Failed to start session" };
10500
+ }
10145
10501
  });
10146
10502
  ipcMain.handle(IPC_CHANNELS.SESSION_STOP, async () => {
10147
- console.log("[Main] Stopping session");
10148
- return actions.stopSession();
10503
+ try {
10504
+ console.log("[Main] Stopping session");
10505
+ return await actions.stopSession();
10506
+ } catch (error) {
10507
+ console.error("[IPC] SESSION_STOP failed:", error);
10508
+ return { success: false, error: error instanceof Error ? error.message : "Failed to stop session" };
10509
+ }
10149
10510
  });
10150
10511
  ipcMain.handle(IPC_CHANNELS.SESSION_PAUSE, async () => {
10151
- console.log("[Main] Pausing session");
10152
- return actions.pauseSession();
10512
+ try {
10513
+ console.log("[Main] Pausing session");
10514
+ return await actions.pauseSession();
10515
+ } catch (error) {
10516
+ console.error("[IPC] SESSION_PAUSE failed:", error);
10517
+ return { success: false, error: error instanceof Error ? error.message : "Failed to pause session" };
10518
+ }
10153
10519
  });
10154
10520
  ipcMain.handle(IPC_CHANNELS.SESSION_RESUME, async () => {
10155
- console.log("[Main] Resuming session");
10156
- return actions.resumeSession();
10521
+ try {
10522
+ console.log("[Main] Resuming session");
10523
+ return await actions.resumeSession();
10524
+ } catch (error) {
10525
+ console.error("[IPC] SESSION_RESUME failed:", error);
10526
+ return { success: false, error: error instanceof Error ? error.message : "Failed to resume session" };
10527
+ }
10157
10528
  });
10158
10529
  ipcMain.handle(IPC_CHANNELS.SESSION_CANCEL, async () => {
10159
- console.log("[Main] Cancelling session");
10160
- return actions.cancelSession();
10530
+ try {
10531
+ console.log("[Main] Cancelling session");
10532
+ return await actions.cancelSession();
10533
+ } catch (error) {
10534
+ console.error("[IPC] SESSION_CANCEL failed:", error);
10535
+ return { success: false, error: error instanceof Error ? error.message : "Failed to cancel session" };
10536
+ }
10161
10537
  });
10162
10538
  ipcMain.handle(IPC_CHANNELS.SESSION_GET_STATUS, () => {
10163
- return sessionController.getStatus();
10539
+ try {
10540
+ return sessionController.getStatus();
10541
+ } catch (error) {
10542
+ console.error("[IPC] SESSION_GET_STATUS failed:", error);
10543
+ return { state: "idle", duration: 0, feedbackCount: 0, screenshotCount: 0, isPaused: false };
10544
+ }
10164
10545
  });
10165
10546
  ipcMain.handle(IPC_CHANNELS.SESSION_GET_CURRENT, () => {
10166
- const session = sessionController.getSession();
10167
- return session ? actions.serializeSession(session) : null;
10547
+ try {
10548
+ const session = sessionController.getSession();
10549
+ return session ? actions.serializeSession(session) : null;
10550
+ } catch (error) {
10551
+ console.error("[IPC] SESSION_GET_CURRENT failed:", error);
10552
+ return null;
10553
+ }
10168
10554
  });
10169
10555
  ipcMain.handle(IPC_CHANNELS.START_SESSION, async (_, sourceId) => {
10170
- return actions.startSession(sourceId);
10556
+ try {
10557
+ return await actions.startSession(sourceId);
10558
+ } catch (error) {
10559
+ console.error("[IPC] START_SESSION failed:", error);
10560
+ return { success: false, error: error instanceof Error ? error.message : "Failed to start session" };
10561
+ }
10171
10562
  });
10172
10563
  ipcMain.handle(IPC_CHANNELS.STOP_SESSION, async () => {
10173
- return actions.stopSession();
10564
+ try {
10565
+ return await actions.stopSession();
10566
+ } catch (error) {
10567
+ console.error("[IPC] STOP_SESSION failed:", error);
10568
+ return { success: false, error: error instanceof Error ? error.message : "Failed to stop session" };
10569
+ }
10174
10570
  });
10175
10571
  }
10176
10572
  const activeScreenRecordings = /* @__PURE__ */ new Map();
10177
10573
  const finalizedScreenRecordings = /* @__PURE__ */ new Map();
10178
10574
  function sleep$1(ms) {
10179
- return new Promise((resolve) => setTimeout(resolve, ms));
10575
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
10180
10576
  }
10181
10577
  function extensionFromMimeType(mimeType) {
10182
10578
  const normalized = (mimeType || "").toLowerCase();
@@ -10314,7 +10710,8 @@ function registerCaptureHandlers(ctx) {
10314
10710
  } else {
10315
10711
  return { success: false, error: "Unsupported recording chunk format." };
10316
10712
  }
10317
- recording.writeChain = recording.writeChain.then(() => fs.appendFile(recording.tempPath, buffer)).then(() => {
10713
+ recording.writeChain = recording.writeChain.catch(() => {
10714
+ }).then(() => fs.appendFile(recording.tempPath, buffer)).then(() => {
10318
10715
  recording.bytesWritten += buffer.byteLength;
10319
10716
  recording.lastChunkAt = Date.now();
10320
10717
  });
@@ -10502,62 +10899,82 @@ function registerSettingsHandlers(ctx, actions) {
10502
10899
  await fs.writeFile(result.filePath, payload, "utf-8");
10503
10900
  });
10504
10901
  ipcMain.handle(IPC_CHANNELS.SETTINGS_IMPORT, async () => {
10505
- const settingsManager2 = getSettingsManager2();
10506
- if (!settingsManager2) {
10507
- return null;
10508
- }
10509
- const mainWindow2 = getMainWindow();
10510
- const options = {
10511
- title: "Import markupr Settings",
10512
- properties: ["openFile"],
10513
- filters: [{ name: "JSON", extensions: ["json"] }]
10514
- };
10515
- const result = mainWindow2 ? await dialog.showOpenDialog(mainWindow2, options) : await dialog.showOpenDialog(options);
10516
- if (result.canceled || result.filePaths.length === 0) {
10517
- return null;
10518
- }
10519
- const raw = await fs.readFile(result.filePaths[0], "utf-8");
10520
- const parsed = JSON.parse(raw);
10521
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
10522
- throw new Error("Invalid settings file format.");
10523
- }
10524
- const entries = Object.entries(parsed);
10525
- const allowedKeys = new Set(Object.keys(DEFAULT_SETTINGS$2));
10526
- const sanitized = {};
10527
- for (const [key, value] of entries) {
10528
- if (!allowedKeys.has(key)) {
10529
- continue;
10902
+ try {
10903
+ const settingsManager2 = getSettingsManager2();
10904
+ if (!settingsManager2) {
10905
+ return null;
10906
+ }
10907
+ const mainWindow2 = getMainWindow();
10908
+ const options = {
10909
+ title: "Import markupr Settings",
10910
+ properties: ["openFile"],
10911
+ filters: [{ name: "JSON", extensions: ["json"] }]
10912
+ };
10913
+ const result = mainWindow2 ? await dialog.showOpenDialog(mainWindow2, options) : await dialog.showOpenDialog(options);
10914
+ if (result.canceled || result.filePaths.length === 0) {
10915
+ return null;
10916
+ }
10917
+ const raw = await fs.readFile(result.filePaths[0], "utf-8");
10918
+ const parsed = JSON.parse(raw);
10919
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
10920
+ console.warn("[Main] Invalid settings file format");
10921
+ return null;
10922
+ }
10923
+ const entries = Object.entries(parsed);
10924
+ const allowedKeys = new Set(Object.keys(DEFAULT_SETTINGS$2));
10925
+ const sanitized = {};
10926
+ for (const [key, value] of entries) {
10927
+ if (!allowedKeys.has(key)) {
10928
+ continue;
10929
+ }
10930
+ if (key === "__proto__" || key === "constructor") {
10931
+ continue;
10932
+ }
10933
+ sanitized[key] = value;
10530
10934
  }
10531
- sanitized[key] = value;
10935
+ return settingsManager2.update(sanitized);
10936
+ } catch (error) {
10937
+ console.error("[Main] Failed to import settings:", error);
10938
+ return null;
10532
10939
  }
10533
- return settingsManager2.update(sanitized);
10534
10940
  });
10535
10941
  ipcMain.handle(IPC_CHANNELS.GET_SETTINGS, () => {
10536
10942
  return getSettingsManager2()?.getAll() ?? { ...DEFAULT_SETTINGS$2 };
10537
10943
  });
10538
10944
  ipcMain.handle(IPC_CHANNELS.SET_SETTINGS, (_, newSettings) => {
10539
- const settings = getSettingsManager2()?.update(newSettings) ?? {
10945
+ if (!newSettings || typeof newSettings !== "object" || Array.isArray(newSettings)) {
10946
+ return getSettingsManager2()?.getAll() ?? { ...DEFAULT_SETTINGS$2 };
10947
+ }
10948
+ const typedSettings = newSettings;
10949
+ const settings = getSettingsManager2()?.update(typedSettings) ?? {
10540
10950
  ...DEFAULT_SETTINGS$2,
10541
- ...newSettings
10951
+ ...typedSettings
10542
10952
  };
10543
- if (newSettings.hotkeys) {
10544
- const results = hotkeyManager.updateConfig(newSettings.hotkeys);
10953
+ if (typedSettings.hotkeys) {
10954
+ const results = hotkeyManager.updateConfig(typedSettings.hotkeys);
10545
10955
  console.log("[Main] Hotkeys updated:", results);
10546
10956
  }
10547
- if (newSettings.hasCompletedOnboarding) {
10957
+ if (typedSettings.hasCompletedOnboarding) {
10548
10958
  setHasCompletedOnboarding(true);
10549
10959
  }
10550
10960
  return settings;
10551
10961
  });
10962
+ const ALLOWED_API_SERVICES = /* @__PURE__ */ new Set(["openai", "anthropic"]);
10552
10963
  ipcMain.handle(
10553
10964
  IPC_CHANNELS.SETTINGS_GET_API_KEY,
10554
10965
  async (_, service) => {
10966
+ if (!ALLOWED_API_SERVICES.has(service)) {
10967
+ return null;
10968
+ }
10555
10969
  return getSettingsManager2()?.getApiKey(service) ?? null;
10556
10970
  }
10557
10971
  );
10558
10972
  ipcMain.handle(
10559
10973
  IPC_CHANNELS.SETTINGS_SET_API_KEY,
10560
10974
  async (_, service, key) => {
10975
+ if (!ALLOWED_API_SERVICES.has(service)) {
10976
+ return false;
10977
+ }
10561
10978
  const settingsManager2 = getSettingsManager2();
10562
10979
  if (!settingsManager2) {
10563
10980
  return false;
@@ -10570,7 +10987,7 @@ function registerSettingsHandlers(ctx, actions) {
10570
10987
  return true;
10571
10988
  }
10572
10989
  if (attempt < 2) {
10573
- await new Promise((resolve) => setTimeout(resolve, 120 * (attempt + 1)));
10990
+ await new Promise((resolve2) => setTimeout(resolve2, 120 * (attempt + 1)));
10574
10991
  }
10575
10992
  }
10576
10993
  if (key.trim().length > 0) {
@@ -10589,6 +11006,9 @@ function registerSettingsHandlers(ctx, actions) {
10589
11006
  ipcMain.handle(
10590
11007
  IPC_CHANNELS.SETTINGS_DELETE_API_KEY,
10591
11008
  async (_, service) => {
11009
+ if (!ALLOWED_API_SERVICES.has(service)) {
11010
+ return false;
11011
+ }
10592
11012
  const settingsManager2 = getSettingsManager2();
10593
11013
  if (!settingsManager2) {
10594
11014
  return false;
@@ -10600,6 +11020,9 @@ function registerSettingsHandlers(ctx, actions) {
10600
11020
  ipcMain.handle(
10601
11021
  IPC_CHANNELS.SETTINGS_HAS_API_KEY,
10602
11022
  async (_, service) => {
11023
+ if (!ALLOWED_API_SERVICES.has(service)) {
11024
+ return false;
11025
+ }
10603
11026
  return getSettingsManager2()?.hasApiKey(service) ?? false;
10604
11027
  }
10605
11028
  );
@@ -10686,7 +11109,8 @@ function registerSettingsHandlers(ctx, actions) {
10686
11109
  return { success: true };
10687
11110
  });
10688
11111
  ipcMain.handle(IPC_CHANNELS.CRASH_RECOVERY_GET_LOGS, (_, limit) => {
10689
- return crashRecovery.getCrashLogs(limit);
11112
+ const sanitizedLimit = typeof limit === "number" && limit > 0 && limit <= 100 ? Math.floor(limit) : void 0;
11113
+ return crashRecovery.getCrashLogs(sanitizedLimit);
10690
11114
  });
10691
11115
  ipcMain.handle(IPC_CHANNELS.CRASH_RECOVERY_CLEAR_LOGS, () => {
10692
11116
  crashRecovery.clearCrashLogs();
@@ -10695,7 +11119,24 @@ function registerSettingsHandlers(ctx, actions) {
10695
11119
  ipcMain.handle(
10696
11120
  IPC_CHANNELS.CRASH_RECOVERY_UPDATE_SETTINGS,
10697
11121
  (_, settings) => {
10698
- crashRecovery.updateSettings(settings);
11122
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
11123
+ return { success: false };
11124
+ }
11125
+ const input = settings;
11126
+ const validated = {};
11127
+ if (typeof input.enableAutoSave === "boolean") {
11128
+ validated.enableAutoSave = input.enableAutoSave;
11129
+ }
11130
+ if (typeof input.autoSaveIntervalMs === "number" && input.autoSaveIntervalMs >= 1e3 && input.autoSaveIntervalMs <= 3e4) {
11131
+ validated.autoSaveIntervalMs = input.autoSaveIntervalMs;
11132
+ }
11133
+ if (typeof input.enableCrashReporting === "boolean") {
11134
+ validated.enableCrashReporting = input.enableCrashReporting;
11135
+ }
11136
+ if (typeof input.maxCrashLogs === "number" && input.maxCrashLogs >= 0 && input.maxCrashLogs <= 100) {
11137
+ validated.maxCrashLogs = input.maxCrashLogs;
11138
+ }
11139
+ crashRecovery.updateSettings(validated);
10699
11140
  return { success: true };
10700
11141
  }
10701
11142
  );
@@ -10761,7 +11202,7 @@ async function exportSessionFolders(sessionIds) {
10761
11202
  const sessions = await listSessionHistoryItems();
10762
11203
  const selected = sessions.filter((session) => sessionIds.includes(session.id));
10763
11204
  if (!selected.length) {
10764
- throw new Error("No sessions found to export.");
11205
+ throw new Error("No matching sessions found. Make sure the selected sessions still exist in your session history.");
10765
11206
  }
10766
11207
  const exportRoot = join(fileManager.getOutputDirectory(), "exports");
10767
11208
  const bundleDir = join(exportRoot, `bundle-${Date.now()}`);
@@ -10817,12 +11258,20 @@ function registerOutputHandlers(ctx) {
10817
11258
  });
10818
11259
  ipcMain.handle(IPC_CHANNELS.OUTPUT_OPEN_FOLDER, async (_, sessionDir) => {
10819
11260
  try {
10820
- const dir = sessionDir || fileManager.getOutputDirectory();
10821
- await shell.openPath(dir);
11261
+ if (sessionDir !== void 0 && typeof sessionDir !== "string") {
11262
+ return { success: false, error: "Invalid directory path" };
11263
+ }
11264
+ const baseDir = fileManager.getOutputDirectory();
11265
+ const dir = sessionDir || baseDir;
11266
+ const resolved = resolve(dir);
11267
+ if (sessionDir && !resolved.startsWith(resolve(baseDir))) {
11268
+ return { success: false, error: "Invalid directory path" };
11269
+ }
11270
+ await shell.openPath(resolved);
10822
11271
  return { success: true };
10823
11272
  } catch (error) {
10824
11273
  console.error("[Main] Failed to open folder:", error);
10825
- return { success: false, error: error.message };
11274
+ return { success: false, error: "Failed to open folder" };
10826
11275
  }
10827
11276
  });
10828
11277
  ipcMain.handle(IPC_CHANNELS.OUTPUT_LIST_SESSIONS, async () => {
@@ -10843,27 +11292,46 @@ function registerOutputHandlers(ctx) {
10843
11292
  });
10844
11293
  ipcMain.handle(IPC_CHANNELS.OUTPUT_DELETE_SESSION, async (_, sessionId) => {
10845
11294
  try {
11295
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
11296
+ return { success: false, error: "Invalid session ID" };
11297
+ }
10846
11298
  const session = await getSessionHistoryItem(sessionId);
10847
11299
  if (!session) {
10848
11300
  return { success: false, error: "Session not found" };
10849
11301
  }
11302
+ const baseDir = fileManager.getOutputDirectory();
11303
+ if (!resolve(session.folder).startsWith(resolve(baseDir))) {
11304
+ return { success: false, error: "Invalid session path" };
11305
+ }
10850
11306
  await fs.rm(session.folder, { recursive: true, force: true });
10851
11307
  return { success: true };
10852
11308
  } catch (error) {
10853
11309
  console.error("[Main] Failed to delete session:", error);
10854
- return { success: false, error: error.message };
11310
+ return { success: false, error: "Failed to delete session" };
10855
11311
  }
10856
11312
  });
10857
11313
  ipcMain.handle(IPC_CHANNELS.OUTPUT_DELETE_SESSIONS, async (_, sessionIds) => {
11314
+ if (!Array.isArray(sessionIds)) {
11315
+ return { success: false, deleted: [], failed: [] };
11316
+ }
10858
11317
  const deleted = [];
10859
11318
  const failed = [];
11319
+ const baseDir = fileManager.getOutputDirectory();
10860
11320
  for (const sessionId of sessionIds) {
10861
11321
  try {
11322
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
11323
+ failed.push(String(sessionId));
11324
+ continue;
11325
+ }
10862
11326
  const session = await getSessionHistoryItem(sessionId);
10863
11327
  if (!session) {
10864
11328
  failed.push(sessionId);
10865
11329
  continue;
10866
11330
  }
11331
+ if (!resolve(session.folder).startsWith(resolve(baseDir))) {
11332
+ failed.push(sessionId);
11333
+ continue;
11334
+ }
10867
11335
  await fs.rm(session.folder, { recursive: true, force: true });
10868
11336
  deleted.push(sessionId);
10869
11337
  } catch {
@@ -10876,16 +11344,21 @@ function registerOutputHandlers(ctx) {
10876
11344
  failed
10877
11345
  };
10878
11346
  });
11347
+ const ALLOWED_EXPORT_FORMATS = /* @__PURE__ */ new Set(["markdown", "json", "pdf"]);
10879
11348
  ipcMain.handle(
10880
11349
  IPC_CHANNELS.OUTPUT_EXPORT_SESSION,
10881
11350
  async (_, sessionId, format = "markdown") => {
10882
11351
  try {
10883
- console.log(`[Main] Exporting session ${sessionId} as ${format}`);
11352
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
11353
+ return { success: false, error: "Invalid session ID" };
11354
+ }
11355
+ const safeFormat = typeof format === "string" && ALLOWED_EXPORT_FORMATS.has(format) ? format : "markdown";
11356
+ console.log(`[Main] Exporting session ${sessionId} as ${safeFormat}`);
10884
11357
  const exportPath = await exportSessionFolders([sessionId]);
10885
11358
  return { success: true, path: exportPath };
10886
11359
  } catch (error) {
10887
11360
  console.error("[Main] Failed to export session:", error);
10888
- return { success: false, error: error.message };
11361
+ return { success: false, error: "Failed to export session" };
10889
11362
  }
10890
11363
  }
10891
11364
  );
@@ -10893,12 +11366,16 @@ function registerOutputHandlers(ctx) {
10893
11366
  IPC_CHANNELS.OUTPUT_EXPORT_SESSIONS,
10894
11367
  async (_, sessionIds, format = "markdown") => {
10895
11368
  try {
10896
- console.log(`[Main] Exporting ${sessionIds.length} sessions as ${format}`);
11369
+ if (!Array.isArray(sessionIds) || sessionIds.some((id) => typeof id !== "string")) {
11370
+ return { success: false, error: "Invalid session IDs" };
11371
+ }
11372
+ const safeFormat = typeof format === "string" && ALLOWED_EXPORT_FORMATS.has(format) ? format : "markdown";
11373
+ console.log(`[Main] Exporting ${sessionIds.length} sessions as ${safeFormat}`);
10897
11374
  const exportPath = await exportSessionFolders(sessionIds);
10898
11375
  return { success: true, path: exportPath };
10899
11376
  } catch (error) {
10900
11377
  console.error("[Main] Failed to export sessions:", error);
10901
- return { success: false, error: error.message };
11378
+ return { success: false, error: "Failed to export sessions" };
10902
11379
  }
10903
11380
  }
10904
11381
  );
@@ -11075,20 +11552,26 @@ function registerWindowHandlers(ctx) {
11075
11552
  return { success: true };
11076
11553
  });
11077
11554
  ipcMain.handle(IPC_CHANNELS.POPOVER_RESIZE, (_, width, height) => {
11555
+ if (typeof width !== "number" || typeof height !== "number" || width < 100 || width > 2e3 || height < 50 || height > 2e3 || !Number.isFinite(width) || !Number.isFinite(height)) {
11556
+ return { success: false, error: "Invalid dimensions" };
11557
+ }
11078
11558
  const popover2 = getPopover();
11079
11559
  if (popover2) {
11080
- popover2.resize(width, height);
11560
+ popover2.resize(Math.round(width), Math.round(height));
11081
11561
  return { success: true };
11082
11562
  }
11083
11563
  return { success: false, error: "Popover not initialized" };
11084
11564
  });
11085
11565
  ipcMain.handle(IPC_CHANNELS.POPOVER_RESIZE_TO_STATE, (_, state) => {
11566
+ if (typeof state !== "string" || !Object.prototype.hasOwnProperty.call(POPOVER_SIZES, state)) {
11567
+ return { success: false, error: "Invalid popover state" };
11568
+ }
11086
11569
  const popover2 = getPopover();
11087
- if (popover2 && state in POPOVER_SIZES) {
11570
+ if (popover2) {
11088
11571
  popover2.resizeToState(state);
11089
11572
  return { success: true };
11090
11573
  }
11091
- return { success: false, error: "Popover not initialized or invalid state" };
11574
+ return { success: false, error: "Popover not initialized" };
11092
11575
  });
11093
11576
  ipcMain.handle(IPC_CHANNELS.POPOVER_SHOW, () => {
11094
11577
  getPopover()?.show();
@@ -11105,20 +11588,28 @@ function registerWindowHandlers(ctx) {
11105
11588
  ipcMain.handle(
11106
11589
  IPC_CHANNELS.TASKBAR_SET_PROGRESS,
11107
11590
  (_, progress) => {
11108
- getWindowsTaskbar()?.setProgress(progress);
11591
+ if (typeof progress !== "number" || !Number.isFinite(progress)) {
11592
+ return { success: false, error: "Invalid progress value" };
11593
+ }
11594
+ getWindowsTaskbar()?.setProgress(Math.max(0, Math.min(1, progress)));
11109
11595
  return { success: true };
11110
11596
  }
11111
11597
  );
11112
11598
  ipcMain.handle(
11113
11599
  IPC_CHANNELS.TASKBAR_FLASH_FRAME,
11114
11600
  (_, count) => {
11115
- getWindowsTaskbar()?.flashFrame(count);
11601
+ const sanitizedCount = typeof count === "number" && Number.isFinite(count) && count > 0 ? Math.min(Math.floor(count), 10) : void 0;
11602
+ getWindowsTaskbar()?.flashFrame(sanitizedCount);
11116
11603
  return { success: true };
11117
11604
  }
11118
11605
  );
11119
11606
  ipcMain.handle(
11120
11607
  IPC_CHANNELS.TASKBAR_SET_OVERLAY,
11121
11608
  (_, state) => {
11609
+ const allowedStates = /* @__PURE__ */ new Set(["recording", "processing", "none"]);
11610
+ if (typeof state !== "string" || !allowedStates.has(state)) {
11611
+ return { success: false, error: "Invalid overlay state" };
11612
+ }
11122
11613
  getWindowsTaskbar()?.setOverlayIcon(state);
11123
11614
  return { success: true };
11124
11615
  }
@@ -11209,7 +11700,11 @@ function registerWindowHandlers(ctx) {
11209
11700
  isDownloaded: modelDownloadManager.isModelDownloaded(info.name)
11210
11701
  }));
11211
11702
  });
11703
+ const ALLOWED_WHISPER_MODELS = /* @__PURE__ */ new Set(["tiny", "base", "small", "medium", "large"]);
11212
11704
  ipcMain.handle(IPC_CHANNELS.WHISPER_DOWNLOAD_MODEL, async (_, model) => {
11705
+ if (typeof model !== "string" || !ALLOWED_WHISPER_MODELS.has(model)) {
11706
+ return { success: false, error: "Invalid model name" };
11707
+ }
11213
11708
  try {
11214
11709
  const unsubProgress = modelDownloadManager.onProgress((progress) => {
11215
11710
  getMainWindow()?.webContents.send(IPC_CHANNELS.WHISPER_DOWNLOAD_PROGRESS, {
@@ -11250,6 +11745,9 @@ function registerWindowHandlers(ctx) {
11250
11745
  }
11251
11746
  });
11252
11747
  ipcMain.handle(IPC_CHANNELS.WHISPER_CANCEL_DOWNLOAD, (_, model) => {
11748
+ if (typeof model !== "string" || !ALLOWED_WHISPER_MODELS.has(model)) {
11749
+ return { success: false, error: "Invalid model name" };
11750
+ }
11253
11751
  modelDownloadManager.cancelDownload(model);
11254
11752
  return { success: true };
11255
11753
  });
@@ -11496,7 +11994,12 @@ let windowsTaskbar = null;
11496
11994
  const DEV_RENDERER_URL = "http://localhost:5173";
11497
11995
  const DEV_RENDERER_LOAD_RETRIES = 10;
11498
11996
  function sleep(ms) {
11499
- return new Promise((resolve) => setTimeout(resolve, ms));
11997
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
11998
+ }
11999
+ function safeSendToRenderer(channel, ...args) {
12000
+ if (mainWindow && !mainWindow.isDestroyed()) {
12001
+ mainWindow.webContents.send(channel, ...args);
12002
+ }
11500
12003
  }
11501
12004
  function attachRendererDiagnostics(window, label) {
11502
12005
  window.on("unresponsive", () => {
@@ -11543,10 +12046,10 @@ function wireAudioTelemetry() {
11543
12046
  teardownAudioTelemetry.forEach((teardown) => teardown());
11544
12047
  teardownAudioTelemetry = [];
11545
12048
  const sendAudioLevel = (level) => {
11546
- mainWindow?.webContents.send(IPC_CHANNELS.AUDIO_LEVEL, level);
12049
+ safeSendToRenderer(IPC_CHANNELS.AUDIO_LEVEL, level);
11547
12050
  };
11548
12051
  const sendVoiceActivity = (active) => {
11549
- mainWindow?.webContents.send(IPC_CHANNELS.AUDIO_VOICE_ACTIVITY, active);
12052
+ safeSendToRenderer(IPC_CHANNELS.AUDIO_VOICE_ACTIVITY, active);
11550
12053
  };
11551
12054
  teardownAudioTelemetry.push(
11552
12055
  audioCapture.onAudioLevel(sendAudioLevel),
@@ -11605,8 +12108,7 @@ function createWindow() {
11605
12108
  preload: preloadPath,
11606
12109
  nodeIntegration: false,
11607
12110
  contextIsolation: true,
11608
- sandbox: false
11609
- // Required for preload to work with contextBridge
12111
+ sandbox: true
11610
12112
  }
11611
12113
  });
11612
12114
  attachRendererDiagnostics(mainWindow, "Main");
@@ -11629,7 +12131,16 @@ function createWindow() {
11629
12131
  mainWindow = null;
11630
12132
  });
11631
12133
  mainWindow.webContents.setWindowOpenHandler(({ url }) => {
11632
- shell.openExternal(url);
12134
+ try {
12135
+ const parsed = new URL(url);
12136
+ if (parsed.protocol === "https:" || parsed.protocol === "http:") {
12137
+ shell.openExternal(url);
12138
+ } else {
12139
+ console.warn(`[Main] Blocked external URL with protocol: ${parsed.protocol}`);
12140
+ }
12141
+ } catch {
12142
+ console.warn(`[Main] Blocked invalid external URL`);
12143
+ }
11633
12144
  return { action: "deny" };
11634
12145
  });
11635
12146
  sessionController.setMainWindow(mainWindow);
@@ -11668,7 +12179,7 @@ function handleSessionStateChange(state, session) {
11668
12179
  console.log(`[Main] Session state changed: ${state}`);
11669
12180
  trayManager.setState(mapToTrayState(state));
11670
12181
  if (state === "recording" && sessionController.isSessionPaused()) {
11671
- trayManager.setTooltip("markupr - Paused (Cmd+Shift+P to resume)");
12182
+ trayManager.setTooltip(`markupr - Paused (${formatHotkeyForDisplay("pauseResume")} to resume)`);
11672
12183
  }
11673
12184
  const keepVisibleOnBlur = state === "starting" || state === "recording" || state === "stopping" || state === "processing";
11674
12185
  popover?.setKeepVisibleOnBlur(keepVisibleOnBlur);
@@ -11680,14 +12191,14 @@ function handleSessionStateChange(state, session) {
11680
12191
  popover.show();
11681
12192
  }
11682
12193
  windowsTaskbar?.updateSessionState(state);
11683
- mainWindow?.webContents.send(IPC_CHANNELS.SESSION_STATE_CHANGED, {
12194
+ safeSendToRenderer(IPC_CHANNELS.SESSION_STATE_CHANGED, {
11684
12195
  state,
11685
12196
  session: session ? serializeSession(session) : null
11686
12197
  });
11687
- mainWindow?.webContents.send(IPC_CHANNELS.SESSION_STATUS, sessionController.getStatus());
12198
+ safeSendToRenderer(IPC_CHANNELS.SESSION_STATUS, sessionController.getStatus());
11688
12199
  }
11689
12200
  function handleFeedbackItem(item) {
11690
- mainWindow?.webContents.send(IPC_CHANNELS.SESSION_FEEDBACK_ITEM, {
12201
+ safeSendToRenderer(IPC_CHANNELS.SESSION_FEEDBACK_ITEM, {
11691
12202
  id: item.id,
11692
12203
  timestamp: item.timestamp,
11693
12204
  text: item.text,
@@ -11713,7 +12224,7 @@ function handleSessionError(error) {
11713
12224
  console.error("[Main] Session error:", error);
11714
12225
  trayManager.setState("error");
11715
12226
  trayManager.setTooltip(`markupr - Error: ${error.message}`);
11716
- mainWindow?.webContents.send(IPC_CHANNELS.SESSION_ERROR, {
12227
+ safeSendToRenderer(IPC_CHANNELS.SESSION_ERROR, {
11717
12228
  message: error.message
11718
12229
  });
11719
12230
  showErrorNotification("Recording Error", error.message);
@@ -11822,15 +12333,22 @@ function handleHotkeyAction(action) {
11822
12333
  console.warn(`[Main] Unknown hotkey action: ${action}`);
11823
12334
  }
11824
12335
  }
12336
+ let toggleRecordingInFlight = false;
11825
12337
  async function handleToggleRecording() {
11826
- const currentState = sessionController.getState();
11827
- if (currentState === "recording") {
11828
- await stopSession();
11829
- } else if (currentState === "idle") {
11830
- const result = await startSession();
11831
- if (!result.success && result.error) {
11832
- showErrorNotification("Unable to Start Recording", result.error);
12338
+ if (toggleRecordingInFlight) return;
12339
+ toggleRecordingInFlight = true;
12340
+ try {
12341
+ const currentState = sessionController.getState();
12342
+ if (currentState === "recording") {
12343
+ await stopSession();
12344
+ } else if (currentState === "idle") {
12345
+ const result = await startSession();
12346
+ if (!result.success && result.error) {
12347
+ showErrorNotification("Unable to Start Recording", result.error);
12348
+ }
11833
12349
  }
12350
+ } finally {
12351
+ toggleRecordingInFlight = false;
11834
12352
  }
11835
12353
  }
11836
12354
  async function handlePauseResume() {
@@ -11860,7 +12378,7 @@ function pauseSession() {
11860
12378
  if (!paused) {
11861
12379
  return { success: false, error: "Session is already paused." };
11862
12380
  }
11863
- trayManager.setTooltip("markupr - Paused (Cmd+Shift+P to resume)");
12381
+ trayManager.setTooltip(`markupr - Paused (${formatHotkeyForDisplay("pauseResume")} to resume)`);
11864
12382
  return { success: true };
11865
12383
  }
11866
12384
  function resumeSession() {
@@ -11871,7 +12389,7 @@ function resumeSession() {
11871
12389
  if (!resumed) {
11872
12390
  return { success: false, error: "Session is not paused." };
11873
12391
  }
11874
- trayManager.setTooltip("markupr - Recording... (Cmd+Shift+F to stop)");
12392
+ trayManager.setTooltip(`markupr - Recording... (${formatHotkeyForDisplay("toggleRecording")} to stop)`);
11875
12393
  return { success: true };
11876
12394
  }
11877
12395
  async function resolveDefaultCaptureSource() {
@@ -11880,7 +12398,8 @@ async function resolveDefaultCaptureSource() {
11880
12398
  thumbnailSize: { width: 1, height: 1 }
11881
12399
  });
11882
12400
  if (!sources.length) {
11883
- throw new Error("No screen capture source is available.");
12401
+ const settingsHint = process.platform === "darwin" ? "System Settings > Privacy & Security > Screen Recording" : process.platform === "win32" ? "Windows Settings > Privacy > Screen capture" : "your system settings";
12402
+ throw new Error(`No screen capture source is available. Check that markupr has screen recording permission in ${settingsHint}.`);
11884
12403
  }
11885
12404
  const primaryDisplayId = String(screen.getPrimaryDisplay().id);
11886
12405
  const preferredSource = sources.find((source) => source.display_id === primaryDisplayId);
@@ -11999,9 +12518,10 @@ async function startSession(sourceId, sourceName) {
11999
12518
  checkPermission("screen")
12000
12519
  ]);
12001
12520
  if (!microphoneGranted || !screenGranted) {
12521
+ const settingsName = process.platform === "darwin" ? "macOS System Settings" : process.platform === "win32" ? "Windows Settings > Privacy" : "your system settings";
12002
12522
  return {
12003
12523
  success: false,
12004
- error: "Microphone and screen recording permissions are required. Enable both in macOS System Settings, then retry."
12524
+ error: `Microphone and screen recording permissions are required. Enable both in ${settingsName}, then retry.`
12005
12525
  };
12006
12526
  }
12007
12527
  let resolvedSourceId = sourceId;
@@ -12064,7 +12584,7 @@ async function stopSession() {
12064
12584
  };
12065
12585
  const emitProcessingProgress = (percent, step) => {
12066
12586
  const boundedPercent = Math.max(0, Math.min(100, Math.round(percent)));
12067
- mainWindow?.webContents.send(IPC_CHANNELS.PROCESSING_PROGRESS, {
12587
+ safeSendToRenderer(IPC_CHANNELS.PROCESSING_PROGRESS, {
12068
12588
  percent: boundedPercent,
12069
12589
  step
12070
12590
  });
@@ -12225,7 +12745,7 @@ async function stopSession() {
12225
12745
  console.log(
12226
12746
  `[Main:stopSession] Step 5/6 complete: post-processing took ${Date.now() - postProcessStartedAt}ms, ${postProcessResult?.transcriptSegments.length ?? 0} segments, ${postProcessResult?.extractedFrames.length ?? 0} frames extracted`
12227
12747
  );
12228
- mainWindow?.webContents.send(IPC_CHANNELS.PROCESSING_COMPLETE, postProcessResult);
12748
+ safeSendToRenderer(IPC_CHANNELS.PROCESSING_COMPLETE, postProcessResult);
12229
12749
  } catch (postProcessError) {
12230
12750
  console.warn("[Main:stopSession] Step 5/6 FAILED: Post-processing pipeline error, continuing with basic output:", postProcessError);
12231
12751
  } finally {
@@ -12287,8 +12807,8 @@ async function stopSession() {
12287
12807
  console.log(
12288
12808
  `[Main:stopSession] All steps complete in ${totalDurationMs}ms (AI: ${aiDurationMs}ms, save: ${saveDurationMs}ms, postProcess: ${postProcessDurationMs}ms). Report: ${saveResult.markdownPath}`
12289
12809
  );
12290
- mainWindow?.webContents.send(IPC_CHANNELS.SESSION_COMPLETE, serializeSession(session));
12291
- mainWindow?.webContents.send(IPC_CHANNELS.OUTPUT_READY, {
12810
+ safeSendToRenderer(IPC_CHANNELS.SESSION_COMPLETE, serializeSession(session));
12811
+ safeSendToRenderer(IPC_CHANNELS.OUTPUT_READY, {
12292
12812
  markdown: markdownForPayload,
12293
12813
  sessionId: session.id,
12294
12814
  path: saveResult.markdownPath,
@@ -12573,6 +13093,9 @@ app.on("will-quit", async () => {
12573
13093
  });
12574
13094
  }
12575
13095
  getFinalizedScreenRecordings().clear();
13096
+ await audioCapture.clearRecoveryBuffers().catch((err) => {
13097
+ console.warn("[Main] Failed to clear audio recovery buffers:", err);
13098
+ });
12576
13099
  teardownAudioTelemetry.forEach((teardown) => teardown());
12577
13100
  teardownAudioTelemetry = [];
12578
13101
  teardownSettingsListeners.forEach((teardown) => teardown());