markupr 2.1.8

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 (299) hide show
  1. package/.claude/commands/review-feedback.md +47 -0
  2. package/.eslintrc.json +35 -0
  3. package/.github/CODEOWNERS +16 -0
  4. package/.github/FUNDING.yml +1 -0
  5. package/.github/ISSUE_TEMPLATE/bug_report.md +56 -0
  6. package/.github/ISSUE_TEMPLATE/feature_request.md +54 -0
  7. package/.github/PULL_REQUEST_TEMPLATE.md +89 -0
  8. package/.github/dependabot.yml +70 -0
  9. package/.github/workflows/ci.yml +184 -0
  10. package/.github/workflows/deploy-landing.yml +134 -0
  11. package/.github/workflows/nightly.yml +288 -0
  12. package/.github/workflows/release.yml +318 -0
  13. package/CHANGELOG.md +127 -0
  14. package/CLAUDE.md +137 -0
  15. package/CODE_OF_CONDUCT.md +9 -0
  16. package/CONTRIBUTING.md +390 -0
  17. package/LICENSE +21 -0
  18. package/PRODUCT_VISION.md +277 -0
  19. package/README.md +517 -0
  20. package/SECURITY.md +51 -0
  21. package/SIGNING_INSTRUCTIONS.md +284 -0
  22. package/assets/DMG_BACKGROUND_INSTRUCTIONS.md +130 -0
  23. package/assets/svg-source/dmg-background.svg +70 -0
  24. package/assets/svg-source/icon.svg +20 -0
  25. package/assets/svg-source/tray-icon-processing.svg +7 -0
  26. package/assets/svg-source/tray-icon-recording.svg +7 -0
  27. package/assets/svg-source/tray-icon.svg +6 -0
  28. package/assets/tray-complete.png +0 -0
  29. package/assets/tray-complete@2x.png +0 -0
  30. package/assets/tray-completeTemplate.png +0 -0
  31. package/assets/tray-completeTemplate@2x.png +0 -0
  32. package/assets/tray-error.png +0 -0
  33. package/assets/tray-error@2x.png +0 -0
  34. package/assets/tray-errorTemplate.png +0 -0
  35. package/assets/tray-errorTemplate@2x.png +0 -0
  36. package/assets/tray-icon-processing.png +0 -0
  37. package/assets/tray-icon-processing@2x.png +0 -0
  38. package/assets/tray-icon-processingTemplate.png +0 -0
  39. package/assets/tray-icon-processingTemplate@2x.png +0 -0
  40. package/assets/tray-icon-recording.png +0 -0
  41. package/assets/tray-icon-recording@2x.png +0 -0
  42. package/assets/tray-icon-recordingTemplate.png +0 -0
  43. package/assets/tray-icon-recordingTemplate@2x.png +0 -0
  44. package/assets/tray-icon.png +0 -0
  45. package/assets/tray-icon@2x.png +0 -0
  46. package/assets/tray-iconTemplate.png +0 -0
  47. package/assets/tray-iconTemplate@2x.png +0 -0
  48. package/assets/tray-idle.png +0 -0
  49. package/assets/tray-idle@2x.png +0 -0
  50. package/assets/tray-idleTemplate.png +0 -0
  51. package/assets/tray-idleTemplate@2x.png +0 -0
  52. package/assets/tray-processing-0.png +0 -0
  53. package/assets/tray-processing-0@2x.png +0 -0
  54. package/assets/tray-processing-0Template.png +0 -0
  55. package/assets/tray-processing-0Template@2x.png +0 -0
  56. package/assets/tray-processing-1.png +0 -0
  57. package/assets/tray-processing-1@2x.png +0 -0
  58. package/assets/tray-processing-1Template.png +0 -0
  59. package/assets/tray-processing-1Template@2x.png +0 -0
  60. package/assets/tray-processing-2.png +0 -0
  61. package/assets/tray-processing-2@2x.png +0 -0
  62. package/assets/tray-processing-2Template.png +0 -0
  63. package/assets/tray-processing-2Template@2x.png +0 -0
  64. package/assets/tray-processing-3.png +0 -0
  65. package/assets/tray-processing-3@2x.png +0 -0
  66. package/assets/tray-processing-3Template.png +0 -0
  67. package/assets/tray-processing-3Template@2x.png +0 -0
  68. package/assets/tray-processing.png +0 -0
  69. package/assets/tray-processing@2x.png +0 -0
  70. package/assets/tray-processingTemplate.png +0 -0
  71. package/assets/tray-processingTemplate@2x.png +0 -0
  72. package/assets/tray-recording.png +0 -0
  73. package/assets/tray-recording@2x.png +0 -0
  74. package/assets/tray-recordingTemplate.png +0 -0
  75. package/assets/tray-recordingTemplate@2x.png +0 -0
  76. package/build/DMG_BACKGROUND_SPEC.md +50 -0
  77. package/build/dmg-background.png +0 -0
  78. package/build/dmg-background@2x.png +0 -0
  79. package/build/entitlements.mac.inherit.plist +27 -0
  80. package/build/entitlements.mac.plist +41 -0
  81. package/build/favicon-16.png +0 -0
  82. package/build/favicon-180.png +0 -0
  83. package/build/favicon-192.png +0 -0
  84. package/build/favicon-32.png +0 -0
  85. package/build/favicon-48.png +0 -0
  86. package/build/favicon-512.png +0 -0
  87. package/build/favicon-64.png +0 -0
  88. package/build/icon-128.png +0 -0
  89. package/build/icon-16.png +0 -0
  90. package/build/icon-24.png +0 -0
  91. package/build/icon-256.png +0 -0
  92. package/build/icon-32.png +0 -0
  93. package/build/icon-48.png +0 -0
  94. package/build/icon-64.png +0 -0
  95. package/build/icon.icns +0 -0
  96. package/build/icon.ico +0 -0
  97. package/build/icon.iconset/icon_128x128.png +0 -0
  98. package/build/icon.iconset/icon_128x128@2x.png +0 -0
  99. package/build/icon.iconset/icon_16x16.png +0 -0
  100. package/build/icon.iconset/icon_16x16@2x.png +0 -0
  101. package/build/icon.iconset/icon_256x256.png +0 -0
  102. package/build/icon.iconset/icon_256x256@2x.png +0 -0
  103. package/build/icon.iconset/icon_32x32.png +0 -0
  104. package/build/icon.iconset/icon_32x32@2x.png +0 -0
  105. package/build/icon.iconset/icon_512x512.png +0 -0
  106. package/build/icon.iconset/icon_512x512@2x.png +0 -0
  107. package/build/icon.png +0 -0
  108. package/build/installer-header.bmp +0 -0
  109. package/build/installer-header.png +0 -0
  110. package/build/installer-sidebar.bmp +0 -0
  111. package/build/installer-sidebar.png +0 -0
  112. package/build/installer.nsh +45 -0
  113. package/build/overlay-processing.png +0 -0
  114. package/build/overlay-recording.png +0 -0
  115. package/build/toolbar-record.png +0 -0
  116. package/build/toolbar-screenshot.png +0 -0
  117. package/build/toolbar-settings.png +0 -0
  118. package/build/toolbar-stop.png +0 -0
  119. package/dist/main/index.mjs +12612 -0
  120. package/dist/preload/index.mjs +907 -0
  121. package/dist/renderer/assets/index-CCmUjl9K.js +19495 -0
  122. package/dist/renderer/assets/index-CUqz_Gs6.css +2270 -0
  123. package/dist/renderer/index.html +27 -0
  124. package/docs/AI_AGENT_QUICKSTART.md +42 -0
  125. package/docs/AI_PIPELINE_DESIGN.md +595 -0
  126. package/docs/API.md +514 -0
  127. package/docs/ARCHITECTURE.md +460 -0
  128. package/docs/CONFIGURATION.md +336 -0
  129. package/docs/DEVELOPMENT.md +508 -0
  130. package/docs/EXPORT_FORMATS.md +451 -0
  131. package/docs/GETTING_STARTED.md +236 -0
  132. package/docs/KEYBOARD_SHORTCUTS.md +334 -0
  133. package/docs/TROUBLESHOOTING.md +418 -0
  134. package/docs/landing/index.html +672 -0
  135. package/docs/landing/script.js +342 -0
  136. package/docs/landing/styles.css +1543 -0
  137. package/electron-builder.yml +140 -0
  138. package/electron.vite.config.ts +63 -0
  139. package/package.json +108 -0
  140. package/railway.json +12 -0
  141. package/scripts/build.mjs +51 -0
  142. package/scripts/generate-icons.mjs +314 -0
  143. package/scripts/generate-installer-images.cjs +253 -0
  144. package/scripts/generate-tray-icons.mjs +258 -0
  145. package/scripts/notarize.cjs +180 -0
  146. package/scripts/one-click-clean-test.sh +147 -0
  147. package/scripts/postinstall.mjs +36 -0
  148. package/scripts/setup-markupr.sh +55 -0
  149. package/setup +17 -0
  150. package/site/index.html +1835 -0
  151. package/site/package.json +11 -0
  152. package/site/railway.json +12 -0
  153. package/site/server.js +31 -0
  154. package/src/main/AutoUpdater.ts +392 -0
  155. package/src/main/CrashRecovery.ts +655 -0
  156. package/src/main/ErrorHandler.ts +703 -0
  157. package/src/main/HotkeyManager.ts +399 -0
  158. package/src/main/MenuManager.ts +529 -0
  159. package/src/main/PermissionManager.ts +420 -0
  160. package/src/main/SessionController.ts +1465 -0
  161. package/src/main/TrayManager.ts +540 -0
  162. package/src/main/ai/AIPipelineManager.ts +199 -0
  163. package/src/main/ai/ClaudeAnalyzer.ts +339 -0
  164. package/src/main/ai/ImageOptimizer.ts +176 -0
  165. package/src/main/ai/StructuredMarkdownBuilder.ts +379 -0
  166. package/src/main/ai/index.ts +16 -0
  167. package/src/main/ai/types.ts +258 -0
  168. package/src/main/analysis/ClarificationGenerator.ts +385 -0
  169. package/src/main/analysis/FeedbackAnalyzer.ts +531 -0
  170. package/src/main/analysis/index.ts +19 -0
  171. package/src/main/audio/AudioCapture.ts +978 -0
  172. package/src/main/audio/audioUtils.ts +100 -0
  173. package/src/main/audio/index.ts +20 -0
  174. package/src/main/capture/index.ts +1 -0
  175. package/src/main/index.ts +1693 -0
  176. package/src/main/ipc/captureHandlers.ts +272 -0
  177. package/src/main/ipc/index.ts +45 -0
  178. package/src/main/ipc/outputHandlers.ts +302 -0
  179. package/src/main/ipc/sessionHandlers.ts +56 -0
  180. package/src/main/ipc/settingsHandlers.ts +471 -0
  181. package/src/main/ipc/types.ts +56 -0
  182. package/src/main/ipc/windowHandlers.ts +277 -0
  183. package/src/main/output/ClipboardService.ts +369 -0
  184. package/src/main/output/ExportService.ts +539 -0
  185. package/src/main/output/FileManager.ts +416 -0
  186. package/src/main/output/MarkdownGenerator.ts +791 -0
  187. package/src/main/output/MarkdownPatcher.ts +299 -0
  188. package/src/main/output/index.ts +186 -0
  189. package/src/main/output/sessionAdapter.ts +207 -0
  190. package/src/main/output/templates/html-template.ts +553 -0
  191. package/src/main/pipeline/FrameExtractor.ts +330 -0
  192. package/src/main/pipeline/PostProcessor.ts +399 -0
  193. package/src/main/pipeline/TranscriptAnalyzer.ts +226 -0
  194. package/src/main/pipeline/index.ts +36 -0
  195. package/src/main/platform/WindowsTaskbar.ts +600 -0
  196. package/src/main/platform/index.ts +16 -0
  197. package/src/main/settings/SettingsManager.ts +730 -0
  198. package/src/main/settings/index.ts +19 -0
  199. package/src/main/transcription/ModelDownloadManager.ts +494 -0
  200. package/src/main/transcription/TierManager.ts +219 -0
  201. package/src/main/transcription/TranscriptionRecoveryService.ts +340 -0
  202. package/src/main/transcription/WhisperService.ts +748 -0
  203. package/src/main/transcription/index.ts +56 -0
  204. package/src/main/transcription/types.ts +135 -0
  205. package/src/main/windows/PopoverManager.ts +284 -0
  206. package/src/main/windows/TaskbarIntegration.ts +452 -0
  207. package/src/main/windows/index.ts +23 -0
  208. package/src/preload/index.ts +1047 -0
  209. package/src/renderer/App.tsx +515 -0
  210. package/src/renderer/AppWrapper.tsx +28 -0
  211. package/src/renderer/assets/logo-dark.svg +7 -0
  212. package/src/renderer/assets/logo.svg +7 -0
  213. package/src/renderer/audio/AudioCaptureRenderer.ts +454 -0
  214. package/src/renderer/capture/ScreenRecordingRenderer.ts +492 -0
  215. package/src/renderer/components/AnnotationOverlay.tsx +836 -0
  216. package/src/renderer/components/AudioWaveform.tsx +811 -0
  217. package/src/renderer/components/ClarificationQuestions.tsx +656 -0
  218. package/src/renderer/components/CountdownTimer.tsx +495 -0
  219. package/src/renderer/components/CrashRecoveryDialog.tsx +632 -0
  220. package/src/renderer/components/DonateButton.tsx +127 -0
  221. package/src/renderer/components/ErrorBoundary.tsx +308 -0
  222. package/src/renderer/components/ExportDialog.tsx +872 -0
  223. package/src/renderer/components/HotkeyHint.tsx +261 -0
  224. package/src/renderer/components/KeyboardShortcuts.tsx +787 -0
  225. package/src/renderer/components/ModelDownloadDialog.tsx +844 -0
  226. package/src/renderer/components/Onboarding.tsx +1830 -0
  227. package/src/renderer/components/ProcessingOverlay.tsx +157 -0
  228. package/src/renderer/components/RecordingOverlay.tsx +423 -0
  229. package/src/renderer/components/SessionHistory.tsx +1746 -0
  230. package/src/renderer/components/SessionReview.tsx +1321 -0
  231. package/src/renderer/components/SettingsPanel.tsx +217 -0
  232. package/src/renderer/components/Skeleton.tsx +347 -0
  233. package/src/renderer/components/StatusIndicator.tsx +86 -0
  234. package/src/renderer/components/ThemeProvider.tsx +429 -0
  235. package/src/renderer/components/Tooltip.tsx +370 -0
  236. package/src/renderer/components/TranscriptionPreview.tsx +183 -0
  237. package/src/renderer/components/TranscriptionTierSelector.tsx +640 -0
  238. package/src/renderer/components/UpdateNotification.tsx +377 -0
  239. package/src/renderer/components/WindowSelector.tsx +947 -0
  240. package/src/renderer/components/index.ts +99 -0
  241. package/src/renderer/components/primitives/ApiKeyInput.tsx +98 -0
  242. package/src/renderer/components/primitives/ColorPicker.tsx +65 -0
  243. package/src/renderer/components/primitives/DangerButton.tsx +45 -0
  244. package/src/renderer/components/primitives/DirectoryPicker.tsx +41 -0
  245. package/src/renderer/components/primitives/Dropdown.tsx +34 -0
  246. package/src/renderer/components/primitives/KeyRecorder.tsx +117 -0
  247. package/src/renderer/components/primitives/SettingsSection.tsx +32 -0
  248. package/src/renderer/components/primitives/Slider.tsx +43 -0
  249. package/src/renderer/components/primitives/Toggle.tsx +36 -0
  250. package/src/renderer/components/primitives/index.ts +10 -0
  251. package/src/renderer/components/settings/AdvancedTab.tsx +174 -0
  252. package/src/renderer/components/settings/AppearanceTab.tsx +77 -0
  253. package/src/renderer/components/settings/GeneralTab.tsx +40 -0
  254. package/src/renderer/components/settings/HotkeysTab.tsx +79 -0
  255. package/src/renderer/components/settings/RecordingTab.tsx +84 -0
  256. package/src/renderer/components/settings/index.ts +9 -0
  257. package/src/renderer/components/settings/settingsStyles.ts +673 -0
  258. package/src/renderer/components/settings/tabConfig.tsx +85 -0
  259. package/src/renderer/components/settings/useSettingsPanel.ts +447 -0
  260. package/src/renderer/contexts/ProcessingContext.tsx +227 -0
  261. package/src/renderer/contexts/RecordingContext.tsx +683 -0
  262. package/src/renderer/contexts/UIContext.tsx +326 -0
  263. package/src/renderer/contexts/index.ts +24 -0
  264. package/src/renderer/donateMessages.ts +69 -0
  265. package/src/renderer/hooks/index.ts +75 -0
  266. package/src/renderer/hooks/useAnimation.tsx +544 -0
  267. package/src/renderer/hooks/useTheme.ts +313 -0
  268. package/src/renderer/index.html +26 -0
  269. package/src/renderer/main.tsx +52 -0
  270. package/src/renderer/styles/animations.css +1093 -0
  271. package/src/renderer/styles/app-shell.css +662 -0
  272. package/src/renderer/styles/globals.css +515 -0
  273. package/src/renderer/styles/theme.ts +578 -0
  274. package/src/renderer/types/electron.d.ts +385 -0
  275. package/src/shared/hotkeys.ts +283 -0
  276. package/src/shared/types.ts +809 -0
  277. package/tests/clipboard.test.ts +228 -0
  278. package/tests/e2e/criticalPaths.test.ts +594 -0
  279. package/tests/feedbackAnalyzer.test.ts +303 -0
  280. package/tests/integration/sessionFlow.test.ts +583 -0
  281. package/tests/markdownGenerator.test.ts +418 -0
  282. package/tests/output.test.ts +96 -0
  283. package/tests/setup.ts +486 -0
  284. package/tests/unit/appIntegration.test.ts +676 -0
  285. package/tests/unit/appViewState.test.ts +281 -0
  286. package/tests/unit/audioIpcChannels.test.ts +17 -0
  287. package/tests/unit/exportService.test.ts +492 -0
  288. package/tests/unit/hotkeys.test.ts +92 -0
  289. package/tests/unit/navigationPreload.test.ts +94 -0
  290. package/tests/unit/onboardingFlow.test.ts +345 -0
  291. package/tests/unit/permissionManager.test.ts +175 -0
  292. package/tests/unit/permissionManagerExpanded.test.ts +296 -0
  293. package/tests/unit/screenRecordingRenderer.test.ts +368 -0
  294. package/tests/unit/sessionController.test.ts +515 -0
  295. package/tests/unit/tierManager.test.ts +61 -0
  296. package/tests/unit/tierManagerExpanded.test.ts +142 -0
  297. package/tests/unit/transcriptAnalyzer.test.ts +64 -0
  298. package/tsconfig.json +25 -0
  299. package/vitest.config.ts +46 -0
@@ -0,0 +1,1693 @@
1
+ /**
2
+ * markupr - Main Process Entry Point
3
+ *
4
+ * This is the orchestration heart of markupr. It:
5
+ * - Initializes all services in the correct order
6
+ * - Wires up the complete session lifecycle
7
+ * - Manages IPC communication with renderer
8
+ * - Handles hotkey registration and tray management
9
+ * - Coordinates graceful shutdown
10
+ *
11
+ * Service Integration Order:
12
+ * 1. Error handler (for crash recovery)
13
+ * 2. Settings (needed for API keys and config)
14
+ * 3. Secure settings + API key availability check
15
+ * 4. Window creation
16
+ * 5. Session controller initialization
17
+ * 6. Transcription service configuration
18
+ * 7. Tray manager initialization
19
+ * 8. Hotkey registration
20
+ * 9. IPC handler setup
21
+ */
22
+
23
+ import {
24
+ app,
25
+ BrowserWindow,
26
+ desktopCapturer,
27
+ screen,
28
+ shell,
29
+ Notification,
30
+ } from 'electron';
31
+ import * as fs from 'fs/promises';
32
+ import { join, dirname, basename, extname } from 'path';
33
+ import { fileURLToPath } from 'url';
34
+
35
+ // Hide dock icon on macOS for pure menu bar experience
36
+ // IMPORTANT: Must be called before app.whenReady()
37
+ if (process.platform === 'darwin') {
38
+ app.dock.hide();
39
+ }
40
+
41
+ // Ensure runtime app identity uses the shipped product name.
42
+ app.setName('markupr');
43
+
44
+ // ESM compatibility - __dirname doesn't exist in ESM
45
+ const __filename = fileURLToPath(import.meta.url);
46
+ const __dirname = dirname(__filename);
47
+ import {
48
+ IPC_CHANNELS,
49
+ type PermissionType,
50
+ type SessionState,
51
+ type SessionPayload,
52
+ type TrayState,
53
+ } from '../shared/types';
54
+ import { hotkeyManager, type HotkeyAction } from './HotkeyManager';
55
+ import { sessionController, type Session } from './SessionController';
56
+ import { trayManager } from './TrayManager';
57
+ import { audioCapture } from './audio/AudioCapture';
58
+ import { SettingsManager } from './settings';
59
+ import { fileManager, clipboardService, generateDocumentForFileManager, adaptSessionForMarkdown } from './output';
60
+ import { processSession as aiProcessSession } from './ai';
61
+ import { modelDownloadManager } from './transcription/ModelDownloadManager';
62
+ import { errorHandler } from './ErrorHandler';
63
+ import { autoUpdaterManager } from './AutoUpdater';
64
+ import { crashRecovery, type RecoverableFeedbackItem } from './CrashRecovery';
65
+ import {
66
+ postProcessor,
67
+ type PostProcessResult,
68
+ type PostProcessProgress,
69
+ type TranscriptSegment,
70
+ } from './pipeline';
71
+ import { menuManager } from './MenuManager';
72
+ import { WindowsTaskbar, createWindowsTaskbar } from './platform';
73
+ import { PopoverManager, POPOVER_SIZES } from './windows';
74
+ import { permissionManager } from './PermissionManager';
75
+ import {
76
+ registerAllHandlers,
77
+ extensionFromMimeType,
78
+ finalizeScreenRecording,
79
+ getScreenRecordingSnapshot,
80
+ deleteFinalizedRecording,
81
+ getActiveScreenRecordings,
82
+ getFinalizedScreenRecordings,
83
+ } from './ipc';
84
+ import {
85
+ extractAiFrameHintsFromMarkdown,
86
+ appendExtractedFramesToReport,
87
+ syncExtractedFrameMetadata,
88
+ syncExtractedFrameSummary,
89
+ writeProcessingTrace,
90
+ } from './output/MarkdownPatcher';
91
+
92
+ // Guard against stdio EIO crashes when the parent terminal/PTY closes.
93
+ type ConsoleMethod = (...args: unknown[]) => void;
94
+
95
+ function wrapConsoleMethod(method: ConsoleMethod): ConsoleMethod {
96
+ return (...args: unknown[]) => {
97
+ try {
98
+ method(...args);
99
+ } catch (error) {
100
+ if (error instanceof Error && error.message.includes('EIO')) {
101
+ return;
102
+ }
103
+ throw error;
104
+ }
105
+ };
106
+ }
107
+
108
+ function isIgnorableStdioError(error: unknown): boolean {
109
+ if (!error) {
110
+ return false;
111
+ }
112
+
113
+ if (typeof error === 'string') {
114
+ return error.toUpperCase().includes('EIO');
115
+ }
116
+
117
+ if (error instanceof Error) {
118
+ if (error.message.toUpperCase().includes('EIO')) {
119
+ return true;
120
+ }
121
+ const withCode = error as Error & { code?: string };
122
+ return withCode.code?.toUpperCase() === 'EIO';
123
+ }
124
+
125
+ if (typeof error === 'object' && 'code' in error) {
126
+ const code = (error as { code?: unknown }).code;
127
+ if (typeof code === 'string' && code.toUpperCase() === 'EIO') {
128
+ return true;
129
+ }
130
+ }
131
+
132
+ return false;
133
+ }
134
+
135
+ console.log = wrapConsoleMethod(console.log.bind(console));
136
+ console.info = wrapConsoleMethod(console.info.bind(console));
137
+ console.warn = wrapConsoleMethod(console.warn.bind(console));
138
+ console.error = wrapConsoleMethod(console.error.bind(console));
139
+
140
+ // =============================================================================
141
+ // Module State
142
+ // =============================================================================
143
+
144
+ let mainWindow: BrowserWindow | null = null;
145
+ let popover: PopoverManager | null = null;
146
+ let settingsManager: SettingsManager | null = null;
147
+ let isQuitting = false;
148
+ let hasCompletedOnboarding = false;
149
+ const rendererRecoveryAttempts = new WeakMap<BrowserWindow, number>();
150
+ let teardownAudioTelemetry: Array<() => void> = [];
151
+ let teardownSettingsListeners: Array<() => void> = [];
152
+
153
+ // Windows taskbar integration (Windows only)
154
+ let windowsTaskbar: WindowsTaskbar | null = null;
155
+
156
+ const DEV_RENDERER_URL = 'http://localhost:5173';
157
+ const DEV_RENDERER_LOAD_RETRIES = 10;
158
+
159
+ function sleep(ms: number): Promise<void> {
160
+ return new Promise((resolve) => setTimeout(resolve, ms));
161
+ }
162
+
163
+ function attachRendererDiagnostics(window: BrowserWindow, label: string): void {
164
+ window.on('unresponsive', () => {
165
+ console.error(`[Main] ${label} window became unresponsive`);
166
+ });
167
+
168
+ window.on('responsive', () => {
169
+ console.log(`[Main] ${label} window responsive again`);
170
+ });
171
+
172
+ window.webContents.on('render-process-gone', (_event, details) => {
173
+ console.error(`[Main] ${label} renderer process gone`, details);
174
+
175
+ const attempts = rendererRecoveryAttempts.get(window) ?? 0;
176
+ if (attempts >= 3) {
177
+ console.error(`[Main] ${label} renderer recovery skipped after ${attempts} failed attempts`);
178
+ return;
179
+ }
180
+
181
+ const nextAttempt = attempts + 1;
182
+ rendererRecoveryAttempts.set(window, nextAttempt);
183
+
184
+ const retryDelayMs = 300 * nextAttempt;
185
+ setTimeout(() => {
186
+ if (window.isDestroyed()) {
187
+ return;
188
+ }
189
+
190
+ void loadRendererIntoWindow(window, `${label} (recovery #${nextAttempt})`)
191
+ .then(() => {
192
+ rendererRecoveryAttempts.set(window, 0);
193
+ console.log(`[Main] ${label} renderer recovered on attempt ${nextAttempt}`);
194
+ })
195
+ .catch((error) => {
196
+ console.error(`[Main] ${label} renderer recovery attempt ${nextAttempt} failed`, error);
197
+ });
198
+ }, retryDelayMs);
199
+ });
200
+
201
+ window.webContents.on(
202
+ 'did-fail-load',
203
+ (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
204
+ if (!isMainFrame) {
205
+ return;
206
+ }
207
+ console.error(
208
+ `[Main] ${label} failed to load renderer (${errorCode}): ${errorDescription} (${validatedURL})`,
209
+ );
210
+ },
211
+ );
212
+ }
213
+
214
+ function wireAudioTelemetry(): void {
215
+ teardownAudioTelemetry.forEach((teardown) => teardown());
216
+ teardownAudioTelemetry = [];
217
+
218
+ const sendAudioLevel = (level: number) => {
219
+ mainWindow?.webContents.send(IPC_CHANNELS.AUDIO_LEVEL, level);
220
+ };
221
+
222
+ const sendVoiceActivity = (active: boolean) => {
223
+ mainWindow?.webContents.send(IPC_CHANNELS.AUDIO_VOICE_ACTIVITY, active);
224
+ };
225
+
226
+ teardownAudioTelemetry.push(
227
+ audioCapture.onAudioLevel(sendAudioLevel),
228
+ audioCapture.onVoiceActivity(sendVoiceActivity),
229
+ );
230
+ }
231
+
232
+ async function loadRendererIntoWindow(window: BrowserWindow, label: string): Promise<void> {
233
+ if (process.env.NODE_ENV !== 'development') {
234
+ await window.loadFile(join(__dirname, '../renderer/index.html'));
235
+ return;
236
+ }
237
+
238
+ let lastError: unknown = null;
239
+ for (let attempt = 1; attempt <= DEV_RENDERER_LOAD_RETRIES; attempt++) {
240
+ try {
241
+ await window.loadURL(DEV_RENDERER_URL);
242
+ window.webContents.openDevTools({ mode: 'detach' });
243
+ return;
244
+ } catch (error) {
245
+ lastError = error;
246
+ const errorMessage = error instanceof Error ? error.message : String(error);
247
+ console.warn(
248
+ `[Main] ${label} renderer load attempt ${attempt}/${DEV_RENDERER_LOAD_RETRIES} failed: ${errorMessage}`,
249
+ );
250
+ await sleep(250 * attempt);
251
+ }
252
+ }
253
+
254
+ const finalMessage =
255
+ lastError instanceof Error ? lastError.message : 'Unknown renderer load failure';
256
+ await window.loadURL(
257
+ `data:text/html;charset=utf-8,${encodeURIComponent(`
258
+ <html>
259
+ <body style="margin:0;padding:20px;background:#121212;color:#f5f5f5;font-family:-apple-system,system-ui,sans-serif;">
260
+ <h2 style="margin:0 0 12px 0;">markupr failed to load</h2>
261
+ <p style="margin:0 0 8px 0;">Dev renderer did not become reachable at ${DEV_RENDERER_URL}.</p>
262
+ <p style="margin:0;color:#b3b3b3;">${finalMessage}</p>
263
+ </body>
264
+ </html>
265
+ `)}`,
266
+ );
267
+ }
268
+
269
+ // =============================================================================
270
+ // Window Management
271
+ // =============================================================================
272
+
273
+ function createWindow(): void {
274
+ // Resolve preload path - works in both dev and production
275
+ const preloadPath = join(app.getAppPath(), 'dist', 'preload', 'index.mjs');
276
+ console.log('[Main] Preload path:', preloadPath);
277
+
278
+ mainWindow = new BrowserWindow({
279
+ width: 400,
280
+ height: 300,
281
+ minWidth: 320,
282
+ minHeight: 200,
283
+ resizable: true,
284
+ frame: false,
285
+ transparent: true,
286
+ alwaysOnTop: true,
287
+ skipTaskbar: false,
288
+ show: false, // Don't show until ready
289
+ webPreferences: {
290
+ preload: preloadPath,
291
+ nodeIntegration: false,
292
+ contextIsolation: true,
293
+ sandbox: false, // Required for preload to work with contextBridge
294
+ },
295
+ });
296
+
297
+ attachRendererDiagnostics(mainWindow, 'Main');
298
+ void loadRendererIntoWindow(mainWindow, 'Main');
299
+
300
+ // Show window when ready
301
+ mainWindow.once('ready-to-show', () => {
302
+ mainWindow?.show();
303
+ console.log('[Main] Window ready to show');
304
+
305
+ // Check if onboarding needed
306
+ if (!hasCompletedOnboarding) {
307
+ mainWindow?.webContents.send(IPC_CHANNELS.SHOW_ONBOARDING);
308
+ }
309
+ });
310
+
311
+ // Handle window close - hide instead of quit on macOS
312
+ mainWindow.on('close', (event) => {
313
+ if (!isQuitting && process.platform === 'darwin') {
314
+ event.preventDefault();
315
+ mainWindow?.hide();
316
+ return;
317
+ }
318
+ });
319
+
320
+ mainWindow.on('closed', () => {
321
+ mainWindow = null;
322
+ });
323
+
324
+ // Handle external links
325
+ mainWindow.webContents.setWindowOpenHandler(({ url }) => {
326
+ shell.openExternal(url);
327
+ return { action: 'deny' };
328
+ });
329
+
330
+ // Set main window on session controller
331
+ sessionController.setMainWindow(mainWindow);
332
+
333
+ console.log('[Main] Window created');
334
+ }
335
+
336
+ /**
337
+ * Show the main window (from tray or dock click)
338
+ * In menu bar mode, shows the popover instead
339
+ */
340
+ function showWindow(): void {
341
+ // In menu bar mode, show the popover
342
+ if (popover) {
343
+ popover.show();
344
+ return;
345
+ }
346
+
347
+ // Fallback for non-popover mode
348
+ if (!mainWindow) {
349
+ createWindow();
350
+ return;
351
+ }
352
+
353
+ if (mainWindow.isMinimized()) {
354
+ mainWindow.restore();
355
+ }
356
+ mainWindow.show();
357
+ mainWindow.focus();
358
+ }
359
+
360
+ // =============================================================================
361
+ // Session State Handling
362
+ // =============================================================================
363
+
364
+ /**
365
+ * Map SessionController state to TrayManager state
366
+ */
367
+ function mapToTrayState(state: SessionState): TrayState {
368
+ switch (state) {
369
+ case 'idle':
370
+ return 'idle';
371
+ case 'recording':
372
+ return 'recording';
373
+ case 'processing':
374
+ return 'processing';
375
+ case 'complete':
376
+ return 'idle';
377
+ default:
378
+ return 'idle';
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Handle session state changes - update tray, Windows taskbar, and notify renderer
384
+ */
385
+ function handleSessionStateChange(state: SessionState, session: Session | null): void {
386
+ console.log(`[Main] Session state changed: ${state}`);
387
+
388
+ // Update tray icon
389
+ trayManager.setState(mapToTrayState(state));
390
+ if (state === 'recording' && sessionController.isSessionPaused()) {
391
+ trayManager.setTooltip('markupr - Paused (Cmd+Shift+P to resume)');
392
+ }
393
+
394
+ const keepVisibleOnBlur =
395
+ state === 'starting' ||
396
+ state === 'recording' ||
397
+ state === 'stopping' ||
398
+ state === 'processing';
399
+ popover?.setKeepVisibleOnBlur(keepVisibleOnBlur);
400
+
401
+ if (popover && (state === 'recording' || state === 'stopping' || state === 'processing')) {
402
+ const hudState = state === 'recording' ? 'recording' : 'processing';
403
+ popover.resizeToState(hudState);
404
+ }
405
+
406
+ if (state === 'recording' && popover && !popover.isVisible()) {
407
+ popover.show();
408
+ }
409
+
410
+ // Update Windows taskbar (if on Windows)
411
+ windowsTaskbar?.updateSessionState(state);
412
+
413
+ // Notify renderer
414
+ mainWindow?.webContents.send(IPC_CHANNELS.SESSION_STATE_CHANGED, {
415
+ state,
416
+ session: session ? serializeSession(session) : null,
417
+ });
418
+
419
+ // Also send status update
420
+ mainWindow?.webContents.send(IPC_CHANNELS.SESSION_STATUS, sessionController.getStatus());
421
+ }
422
+
423
+ /**
424
+ * Handle new feedback item
425
+ */
426
+ function handleFeedbackItem(item: {
427
+ id: string;
428
+ timestamp: number;
429
+ text: string;
430
+ confidence: number;
431
+ }): void {
432
+ mainWindow?.webContents.send(IPC_CHANNELS.SESSION_FEEDBACK_ITEM, {
433
+ id: item.id,
434
+ timestamp: item.timestamp,
435
+ text: item.text,
436
+ confidence: item.confidence,
437
+ hasScreenshot: false,
438
+ });
439
+
440
+ // Update crash recovery with new feedback item
441
+ const session = sessionController.getSession();
442
+ if (session) {
443
+ const recoverableFeedbackItems: RecoverableFeedbackItem[] = session.feedbackItems.map((fi) => ({
444
+ id: fi.id,
445
+ timestamp: fi.timestamp,
446
+ text: fi.text,
447
+ confidence: fi.confidence,
448
+ hasScreenshot: false,
449
+ }));
450
+
451
+ crashRecovery.updateSession({
452
+ feedbackItems: recoverableFeedbackItems,
453
+ screenshotCount: sessionController.getStatus().screenshotCount,
454
+ });
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Handle session errors
460
+ */
461
+ function handleSessionError(error: Error): void {
462
+ console.error('[Main] Session error:', error);
463
+
464
+ // Update tray to error state
465
+ trayManager.setState('error');
466
+ trayManager.setTooltip(`markupr - Error: ${error.message}`);
467
+
468
+ // Notify renderer
469
+ mainWindow?.webContents.send(IPC_CHANNELS.SESSION_ERROR, {
470
+ message: error.message,
471
+ });
472
+
473
+ // Show notification
474
+ showErrorNotification('Recording Error', error.message);
475
+ }
476
+
477
+ // =============================================================================
478
+ // Tray Handling
479
+ // =============================================================================
480
+
481
+ /**
482
+ * Handle tray icon click - toggle popover for menu bar mode
483
+ */
484
+ function handleTrayClick(): void {
485
+ // In menu bar mode, tray click toggles the popover
486
+ if (popover) {
487
+ popover.toggle();
488
+ return;
489
+ }
490
+
491
+ // Fallback for non-popover mode
492
+ const currentState = sessionController.getState();
493
+
494
+ if (currentState === 'idle') {
495
+ // Show window to start a new session
496
+ showWindow();
497
+ mainWindow?.webContents.send(IPC_CHANNELS.SHOW_WINDOW_SELECTOR);
498
+ } else if (currentState === 'recording') {
499
+ // Stop recording
500
+ stopSession();
501
+ } else {
502
+ // Just show the window
503
+ showWindow();
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Handle settings click from tray menu
509
+ */
510
+ function handleSettingsClick(): void {
511
+ showWindow();
512
+ mainWindow?.webContents.send(IPC_CHANNELS.SHOW_SETTINGS);
513
+ }
514
+
515
+ /**
516
+ * Handle menu bar actions from MenuManager
517
+ */
518
+ function handleMenuAction(action: string, data?: unknown): void {
519
+ console.log(`[Main] Menu action: ${action}`, data);
520
+
521
+ switch (action) {
522
+ case 'toggle-recording':
523
+ handleToggleRecording();
524
+ break;
525
+ case 'show-settings':
526
+ handleSettingsClick();
527
+ break;
528
+ case 'show-history':
529
+ showWindow();
530
+ mainWindow?.webContents.send(IPC_CHANNELS.SHOW_HISTORY);
531
+ break;
532
+ case 'show-export':
533
+ showWindow();
534
+ mainWindow?.webContents.send(IPC_CHANNELS.SHOW_EXPORT);
535
+ break;
536
+ case 'show-shortcuts':
537
+ showWindow();
538
+ mainWindow?.webContents.send(IPC_CHANNELS.SHOW_SHORTCUTS);
539
+ break;
540
+ case 'check-updates':
541
+ autoUpdaterManager.checkForUpdates();
542
+ break;
543
+ case 'open-session':
544
+ showWindow();
545
+ mainWindow?.webContents.send(IPC_CHANNELS.OPEN_SESSION_DIALOG);
546
+ break;
547
+ case 'open-session-path':
548
+ if (data && typeof data === 'object' && 'path' in data) {
549
+ showWindow();
550
+ mainWindow?.webContents.send(IPC_CHANNELS.OPEN_SESSION, (data as { path: string }).path);
551
+ }
552
+ break;
553
+ default:
554
+ console.warn(`[Main] Unknown menu action: ${action}`);
555
+ }
556
+ }
557
+
558
+ // =============================================================================
559
+ // Notifications
560
+ // =============================================================================
561
+
562
+ function showSuccessNotification(title: string, body: string): void {
563
+ if (Notification.isSupported()) {
564
+ const notification = new Notification({ title, body, silent: false });
565
+ notification.show();
566
+ }
567
+ }
568
+
569
+ function showErrorNotification(title: string, body: string): void {
570
+ if (body.toUpperCase().includes('EIO')) {
571
+ return;
572
+ }
573
+
574
+ if (Notification.isSupported()) {
575
+ const notification = new Notification({ title, body, silent: false, urgency: 'critical' });
576
+ notification.show();
577
+ }
578
+ }
579
+
580
+ // =============================================================================
581
+ // Hotkey Management
582
+ // =============================================================================
583
+
584
+ function initializeHotkeys(): void {
585
+ const results = hotkeyManager.initialize();
586
+
587
+ for (const result of results) {
588
+ if (result.success) {
589
+ console.log(`[Main] Hotkey registered: ${result.action} -> ${result.accelerator}`);
590
+ if (result.fallbackUsed) {
591
+ console.log(`[Main] (Used fallback: ${result.fallbackUsed})`);
592
+ }
593
+ } else {
594
+ console.error(`[Main] Failed to register hotkey for ${result.action}: ${result.error}`);
595
+ }
596
+ }
597
+
598
+ hotkeyManager.onHotkey((action: HotkeyAction) => {
599
+ handleHotkeyAction(action);
600
+ });
601
+ }
602
+
603
+ function handleHotkeyAction(action: HotkeyAction): void {
604
+ console.log(`[Main] Hotkey triggered: ${action}`);
605
+
606
+ switch (action) {
607
+ case 'toggleRecording':
608
+ void handleToggleRecording();
609
+ break;
610
+
611
+ case 'manualScreenshot':
612
+ void handleManualScreenshot();
613
+ break;
614
+
615
+ case 'pauseResume':
616
+ void handlePauseResume();
617
+ break;
618
+
619
+ default:
620
+ console.warn(`[Main] Unknown hotkey action: ${action}`);
621
+ }
622
+ }
623
+
624
+ async function handleToggleRecording(): Promise<void> {
625
+ const currentState = sessionController.getState();
626
+
627
+ if (currentState === 'recording') {
628
+ // Stop recording
629
+ await stopSession();
630
+ } else if (currentState === 'idle') {
631
+ const result = await startSession();
632
+ if (!result.success && result.error) {
633
+ showErrorNotification('Unable to Start Recording', result.error);
634
+ }
635
+ }
636
+ }
637
+
638
+ async function handlePauseResume(): Promise<void> {
639
+ if (sessionController.getState() !== 'recording') {
640
+ return;
641
+ }
642
+
643
+ if (sessionController.isSessionPaused()) {
644
+ resumeSession();
645
+ return;
646
+ }
647
+
648
+ pauseSession();
649
+ }
650
+
651
+ async function handleManualScreenshot(): Promise<void> {
652
+ const cue = sessionController.registerCaptureCue('manual');
653
+ if (!cue) {
654
+ return;
655
+ }
656
+
657
+ crashRecovery.updateSession({
658
+ screenshotCount: cue.count,
659
+ });
660
+ }
661
+
662
+ function pauseSession(): { success: boolean; error?: string } {
663
+ if (sessionController.getState() !== 'recording') {
664
+ return { success: false, error: 'No recording session is active.' };
665
+ }
666
+
667
+ const paused = sessionController.pause();
668
+ if (!paused) {
669
+ return { success: false, error: 'Session is already paused.' };
670
+ }
671
+
672
+ trayManager.setTooltip('markupr - Paused (Cmd+Shift+P to resume)');
673
+ return { success: true };
674
+ }
675
+
676
+ function resumeSession(): { success: boolean; error?: string } {
677
+ if (sessionController.getState() !== 'recording') {
678
+ return { success: false, error: 'No recording session is active.' };
679
+ }
680
+
681
+ const resumed = sessionController.resume();
682
+ if (!resumed) {
683
+ return { success: false, error: 'Session is not paused.' };
684
+ }
685
+
686
+ trayManager.setTooltip('markupr - Recording... (Cmd+Shift+F to stop)');
687
+ return { success: true };
688
+ }
689
+
690
+ // =============================================================================
691
+ // Session Control
692
+ // =============================================================================
693
+
694
+ /**
695
+ * Resolve the default capture source for zero-friction recording start.
696
+ * Prefers the primary display to match what users are actively looking at.
697
+ */
698
+ async function resolveDefaultCaptureSource(): Promise<{ sourceId: string; sourceName: string }> {
699
+ const sources = await desktopCapturer.getSources({
700
+ types: ['screen'],
701
+ thumbnailSize: { width: 1, height: 1 },
702
+ });
703
+
704
+ if (!sources.length) {
705
+ throw new Error('No screen capture source is available.');
706
+ }
707
+
708
+ const primaryDisplayId = String(screen.getPrimaryDisplay().id);
709
+ const preferredSource = sources.find((source) => source.display_id === primaryDisplayId);
710
+ const fallbackSource = sources.find((source) => source.id.startsWith('screen')) || sources[0];
711
+ const selected = preferredSource || fallbackSource;
712
+
713
+ return {
714
+ sourceId: selected.id,
715
+ sourceName: selected.name || 'Main Display',
716
+ };
717
+ }
718
+
719
+ function buildPostProcessTranscriptSegments(session: Session): TranscriptSegment[] {
720
+ const sessionStartSec = session.startTime / 1000;
721
+ const events = session.transcriptBuffer
722
+ .filter((event) => event.text.trim().length > 0 && event.isFinal)
723
+ .sort((a, b) => a.timestamp - b.timestamp);
724
+
725
+ if (events.length === 0) {
726
+ return [];
727
+ }
728
+
729
+ return events.map((event, index) => {
730
+ const startTime = Math.max(0, event.timestamp - sessionStartSec);
731
+ const nextTimestampSec =
732
+ index < events.length - 1
733
+ ? Math.max(startTime + 0.35, events[index + 1].timestamp - sessionStartSec)
734
+ : startTime + Math.min(3, Math.max(1, event.text.trim().split(/\s+/).length * 0.35));
735
+ const endTime = Math.max(startTime + 0.35, nextTimestampSec);
736
+
737
+ return {
738
+ text: event.text.trim(),
739
+ startTime,
740
+ endTime,
741
+ confidence: Number.isFinite(event.confidence) ? event.confidence : 0.8,
742
+ };
743
+ });
744
+ }
745
+
746
+ async function copyReportPathToClipboard(path: string): Promise<boolean> {
747
+ const maxAttempts = 3;
748
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
749
+ const copied = await clipboardService.copy(path);
750
+ if (copied) {
751
+ return true;
752
+ }
753
+
754
+ if (attempt < maxAttempts) {
755
+ await sleep(120 * attempt);
756
+ }
757
+ }
758
+
759
+ return false;
760
+ }
761
+
762
+ async function attachRecordingToSessionOutput(
763
+ sessionId: string,
764
+ sessionDir: string,
765
+ markdownPath: string
766
+ ): Promise<{ path: string; mimeType: string; bytesWritten: number; startTime?: number } | undefined> {
767
+ const artifact = await finalizeScreenRecording(sessionId);
768
+ if (!artifact || artifact.bytesWritten <= 0) {
769
+ return undefined;
770
+ }
771
+
772
+ const extension = extname(artifact.tempPath) || extensionFromMimeType(artifact.mimeType);
773
+ const finalPath = join(sessionDir, `session-recording${extension}`);
774
+
775
+ try {
776
+ await fs.copyFile(artifact.tempPath, finalPath);
777
+
778
+ // Append recording link to the report for agent context replay.
779
+ let markdown = await fs.readFile(markdownPath, 'utf-8');
780
+ if (!markdown.includes('## Session Recording')) {
781
+ markdown += `\n## Session Recording\n- [Open full recording](./${basename(finalPath)})\n`;
782
+ await fs.writeFile(markdownPath, markdown, 'utf-8');
783
+ }
784
+
785
+ return {
786
+ path: finalPath,
787
+ mimeType: artifact.mimeType,
788
+ bytesWritten: artifact.bytesWritten,
789
+ startTime: artifact.startTime,
790
+ };
791
+ } catch (error) {
792
+ console.warn('[Main] Failed to attach session recording to output:', error);
793
+ return undefined;
794
+ } finally {
795
+ deleteFinalizedRecording(sessionId);
796
+ await fs.unlink(artifact.tempPath).catch(() => {
797
+ // Best-effort cleanup for temp artifacts.
798
+ });
799
+ }
800
+ }
801
+
802
+ async function attachAudioToSessionOutput(
803
+ sessionDir: string,
804
+ markdownPath: string
805
+ ): Promise<{ path: string; bytesWritten: number; durationMs: number; mimeType: string } | undefined> {
806
+ const basePath = join(sessionDir, 'session-audio');
807
+
808
+ try {
809
+ const exported = await sessionController.exportCapturedAudio(basePath);
810
+ if (!exported || exported.bytesWritten <= 0) {
811
+ return undefined;
812
+ }
813
+
814
+ let markdown = await fs.readFile(markdownPath, 'utf-8');
815
+ if (!markdown.includes('## Session Audio')) {
816
+ markdown += `\n## Session Audio\n- [Open narration audio](./${basename(exported.path)})\n`;
817
+ await fs.writeFile(markdownPath, markdown, 'utf-8');
818
+ }
819
+
820
+ return {
821
+ path: exported.path,
822
+ bytesWritten: exported.bytesWritten,
823
+ durationMs: exported.durationMs,
824
+ mimeType: exported.mimeType,
825
+ };
826
+ } catch (error) {
827
+ console.warn('[Main] Failed to attach session audio to output:', error);
828
+ return undefined;
829
+ } finally {
830
+ sessionController.clearCapturedAudio();
831
+ }
832
+ }
833
+
834
+ /**
835
+ * Start a recording session.
836
+ */
837
+ async function startSession(sourceId?: string, sourceName?: string): Promise<{
838
+ success: boolean;
839
+ sessionId?: string;
840
+ error?: string;
841
+ }> {
842
+ try {
843
+ const [hasMicrophonePermission, hasScreenPermission] = await Promise.all([
844
+ checkPermission('microphone'),
845
+ checkPermission('screen'),
846
+ ]);
847
+
848
+ if (!hasMicrophonePermission) {
849
+ await requestPermission('microphone');
850
+ }
851
+ if (!hasScreenPermission) {
852
+ await requestPermission('screen');
853
+ }
854
+
855
+ const [microphoneGranted, screenGranted] = await Promise.all([
856
+ checkPermission('microphone'),
857
+ checkPermission('screen'),
858
+ ]);
859
+
860
+ if (!microphoneGranted || !screenGranted) {
861
+ return {
862
+ success: false,
863
+ error:
864
+ 'Microphone and screen recording permissions are required. Enable both in macOS System Settings, then retry.',
865
+ };
866
+ }
867
+
868
+ let resolvedSourceId = sourceId;
869
+ let resolvedSourceName = sourceName;
870
+
871
+ if (!resolvedSourceId) {
872
+ const defaultSource = await resolveDefaultCaptureSource();
873
+ resolvedSourceId = defaultSource.sourceId;
874
+ resolvedSourceName = defaultSource.sourceName;
875
+ }
876
+
877
+ // Start the session
878
+ await sessionController.start(resolvedSourceId, resolvedSourceName);
879
+
880
+ const session = sessionController.getSession();
881
+
882
+ // Start crash recovery tracking
883
+ if (session) {
884
+ crashRecovery.startTracking({
885
+ id: session.id,
886
+ startTime: session.startTime,
887
+ lastSaveTime: Date.now(),
888
+ feedbackItems: [],
889
+ transcriptionBuffer: '',
890
+ sourceId: session.sourceId,
891
+ sourceName: resolvedSourceName || 'Unknown Source',
892
+ screenshotCount: 0,
893
+ metadata: {
894
+ appVersion: app.getVersion(),
895
+ platform: process.platform,
896
+ sessionDurationMs: 0,
897
+ },
898
+ });
899
+ }
900
+
901
+ return {
902
+ success: true,
903
+ sessionId: session?.id,
904
+ };
905
+ } catch (error) {
906
+ console.error('[Main] Failed to start session:', error);
907
+ const message = error instanceof Error ? error.message : 'Unknown error';
908
+ return {
909
+ success: false,
910
+ error: message,
911
+ };
912
+ }
913
+ }
914
+
915
+ /**
916
+ * Stop the current recording session and generate output
917
+ */
918
+ async function stopSession(): Promise<{
919
+ success: boolean;
920
+ session?: SessionPayload;
921
+ reportPath?: string;
922
+ error?: string;
923
+ }> {
924
+ let stoppedSessionId: string | null = null;
925
+ const stopStartedAt = Date.now();
926
+ let aiDurationMs = 0;
927
+ let saveDurationMs = 0;
928
+ let postProcessDurationMs = 0;
929
+ let aiFrameHintCount = 0;
930
+ let stopPhaseTicker: NodeJS.Timeout | null = null;
931
+ let stopPhasePercent = 6;
932
+
933
+ const cleanupRecordingArtifacts = async (sessionId: string): Promise<void> => {
934
+ const artifact = await finalizeScreenRecording(sessionId).catch(() => null);
935
+ if (!artifact?.tempPath) {
936
+ deleteFinalizedRecording(sessionId);
937
+ return;
938
+ }
939
+
940
+ await fs.unlink(artifact.tempPath).catch(() => {
941
+ // Best-effort cleanup of orphaned temp recordings.
942
+ });
943
+ deleteFinalizedRecording(sessionId);
944
+ };
945
+
946
+ const emitProcessingProgress = (percent: number, step: string): void => {
947
+ const boundedPercent = Math.max(0, Math.min(100, Math.round(percent)));
948
+ mainWindow?.webContents.send(IPC_CHANNELS.PROCESSING_PROGRESS, {
949
+ percent: boundedPercent,
950
+ step,
951
+ });
952
+ };
953
+
954
+ const stopStopPhaseTicker = (): void => {
955
+ if (stopPhaseTicker) {
956
+ clearInterval(stopPhaseTicker);
957
+ stopPhaseTicker = null;
958
+ }
959
+ };
960
+
961
+ const startStopPhaseTicker = (): void => {
962
+ stopStopPhaseTicker();
963
+ emitProcessingProgress(stopPhasePercent, 'preparing');
964
+ stopPhaseTicker = setInterval(() => {
965
+ stopPhasePercent = Math.min(32, stopPhasePercent + 1);
966
+ emitProcessingProgress(stopPhasePercent, 'preparing');
967
+ windowsTaskbar?.setProgress(Math.max(0.06, stopPhasePercent / 100));
968
+ if (stopPhasePercent >= 32) {
969
+ stopStopPhaseTicker();
970
+ }
971
+ }, 420);
972
+ };
973
+
974
+ try {
975
+ // Set Windows taskbar to processing state with indeterminate progress
976
+ windowsTaskbar?.setProgress(-1);
977
+ startStopPhaseTicker();
978
+
979
+ // Stop the session and get results
980
+ console.log('[Main:stopSession] Step 1/6: Stopping session controller...');
981
+ const session = await sessionController.stop();
982
+ stopStopPhaseTicker();
983
+
984
+ // Stop crash recovery tracking
985
+ crashRecovery.stopTracking();
986
+
987
+ if (!session) {
988
+ windowsTaskbar?.clearProgress();
989
+ return {
990
+ success: false,
991
+ error: 'No active session to stop',
992
+ };
993
+ }
994
+ stoppedSessionId = session.id;
995
+ console.log(
996
+ `[Main:stopSession] Session stopped: ${session.id}, ` +
997
+ `${session.feedbackItems.length} feedback items, ` +
998
+ `${session.transcriptBuffer.length} transcript events`
999
+ );
1000
+
1001
+ const recordingProbe = getScreenRecordingSnapshot(session.id);
1002
+ const hasTranscript = session.transcriptBuffer.some((entry) => entry.text.trim().length > 0);
1003
+ const hasRecording = !!recordingProbe && recordingProbe.bytesWritten > 0;
1004
+ const recordingExtension = hasRecording
1005
+ ? extname(recordingProbe?.tempPath ?? '') || extensionFromMimeType(recordingProbe?.mimeType)
1006
+ : '.webm';
1007
+ const recordingFilename = `session-recording${recordingExtension}`;
1008
+
1009
+ if (!hasTranscript && !hasRecording) {
1010
+ await cleanupRecordingArtifacts(session.id);
1011
+ sessionController.clearCapturedAudio();
1012
+ windowsTaskbar?.clearProgress();
1013
+ return {
1014
+ success: false,
1015
+ error:
1016
+ 'No capture data was collected (no transcript or recording). Check microphone/screen capture access and retry.',
1017
+ };
1018
+ }
1019
+
1020
+ emitProcessingProgress(10, 'preparing');
1021
+
1022
+ // Update progress: generating document (33%)
1023
+ windowsTaskbar?.setProgress(0.33);
1024
+ emitProcessingProgress(24, 'analyzing');
1025
+
1026
+ // Generate output document -- uses AI pipeline if an Anthropic key is configured,
1027
+ // otherwise falls back to the free-tier rule-based generator.
1028
+ console.log(
1029
+ `[Main:stopSession] Step 2/6: Running AI analysis pipeline ` +
1030
+ `(settingsManager ${settingsManager ? 'available' : 'NOT available'}, ` +
1031
+ `hasTranscript=${hasTranscript}, hasRecording=${hasRecording})...`
1032
+ );
1033
+ const aiStartedAt = Date.now();
1034
+ let aiTier: 'free' | 'byok' | 'premium' = 'free';
1035
+ let aiEnhanced = false;
1036
+ let aiFallbackReason: string | undefined;
1037
+ const { document } = settingsManager
1038
+ ? await aiProcessSession(session, {
1039
+ settingsManager,
1040
+ projectName: session.metadata?.sourceName || 'Feedback Session',
1041
+ screenshotDir: './screenshots',
1042
+ hasRecording,
1043
+ recordingFilename,
1044
+ }).then((result) => {
1045
+ aiTier = result.pipelineOutput.tier;
1046
+ aiEnhanced = result.pipelineOutput.aiEnhanced;
1047
+ aiFallbackReason = result.pipelineOutput.fallbackReason;
1048
+ return result;
1049
+ })
1050
+ : {
1051
+ document: generateDocumentForFileManager(session, {
1052
+ projectName: session.metadata?.sourceName || 'Feedback Session',
1053
+ screenshotDir: './screenshots',
1054
+ }),
1055
+ };
1056
+ aiDurationMs = Date.now() - aiStartedAt;
1057
+ console.log(
1058
+ `[Main:stopSession] Step 2/6 complete: AI analysis took ${aiDurationMs}ms ` +
1059
+ `(tier=${aiTier}, aiEnhanced=${aiEnhanced}${aiFallbackReason ? `, fallback=${aiFallbackReason}` : ''})`
1060
+ );
1061
+ emitProcessingProgress(44, 'analyzing');
1062
+
1063
+ // Update progress: saving to file system (66%)
1064
+ windowsTaskbar?.setProgress(0.66);
1065
+ emitProcessingProgress(56, 'saving');
1066
+
1067
+ // Save to file system
1068
+ console.log('[Main:stopSession] Step 3/6: Saving session to file system...');
1069
+ const saveStartedAt = Date.now();
1070
+ const saveResult = await fileManager.saveSession(session, document);
1071
+ saveDurationMs = Date.now() - saveStartedAt;
1072
+ console.log(`[Main:stopSession] Step 3/6 complete: save took ${saveDurationMs}ms`);
1073
+ if (!saveResult.success) {
1074
+ await cleanupRecordingArtifacts(session.id);
1075
+ sessionController.clearCapturedAudio();
1076
+ windowsTaskbar?.clearProgress();
1077
+ return {
1078
+ success: false,
1079
+ error: saveResult.error || 'Unable to save session report.',
1080
+ };
1081
+ }
1082
+ emitProcessingProgress(64, 'saving');
1083
+
1084
+ console.log('[Main:stopSession] Step 4/6: Attaching recording and audio artifacts...');
1085
+ const recordingArtifact = await attachRecordingToSessionOutput(
1086
+ session.id,
1087
+ saveResult.sessionDir,
1088
+ saveResult.markdownPath
1089
+ );
1090
+
1091
+ const audioArtifact = await attachAudioToSessionOutput(
1092
+ saveResult.sessionDir,
1093
+ saveResult.markdownPath
1094
+ );
1095
+
1096
+ if (recordingArtifact) {
1097
+ sessionController.setSessionMetadata({
1098
+ recordingPath: recordingArtifact.path,
1099
+ recordingMimeType: recordingArtifact.mimeType,
1100
+ recordingBytes: recordingArtifact.bytesWritten,
1101
+ });
1102
+ }
1103
+ if (audioArtifact) {
1104
+ sessionController.setSessionMetadata({
1105
+ audioPath: audioArtifact.path,
1106
+ audioBytes: audioArtifact.bytesWritten,
1107
+ audioDurationMs: audioArtifact.durationMs,
1108
+ });
1109
+ }
1110
+ console.log(
1111
+ `[Main:stopSession] Step 4/6 complete: recording=${recordingArtifact ? `${recordingArtifact.bytesWritten}B` : 'none'}, ` +
1112
+ `audio=${audioArtifact ? `${audioArtifact.bytesWritten}B, ${audioArtifact.durationMs}ms` : 'none'}`
1113
+ );
1114
+ emitProcessingProgress(71, 'preparing');
1115
+
1116
+ // ------------------------------------------------------------------
1117
+ // Post-Processing Pipeline
1118
+ // ------------------------------------------------------------------
1119
+ // Run the post-processor if we have audio and/or video artifacts.
1120
+ // Progress and completion events are sent to the renderer via IPC.
1121
+ let postProcessResult: PostProcessResult | null = null;
1122
+ const providedTranscriptSegments = buildPostProcessTranscriptSegments(session);
1123
+ const aiMomentHints = extractAiFrameHintsFromMarkdown(
1124
+ document.content,
1125
+ providedTranscriptSegments
1126
+ );
1127
+ aiFrameHintCount = aiMomentHints.length;
1128
+
1129
+ console.log(
1130
+ `[Main:stopSession] Step 5/6: Post-processing pipeline ` +
1131
+ `(${providedTranscriptSegments.length} pre-provided segments, ` +
1132
+ `${aiMomentHints.length} AI frame hints, ` +
1133
+ `hasAudio=${!!audioArtifact}, hasRecording=${!!recordingArtifact})...`
1134
+ );
1135
+
1136
+ if (audioArtifact || recordingArtifact) {
1137
+ const postProcessStartedAt = Date.now();
1138
+ emitProcessingProgress(74, 'transcribing');
1139
+ try {
1140
+ postProcessResult = await postProcessor.process({
1141
+ videoPath: recordingArtifact?.path ?? '',
1142
+ audioPath: audioArtifact?.path ?? '',
1143
+ sessionDir: saveResult.sessionDir,
1144
+ aiMomentHints,
1145
+ transcriptSegments:
1146
+ providedTranscriptSegments.length > 0
1147
+ ? providedTranscriptSegments
1148
+ : undefined,
1149
+ onProgress: (progress: PostProcessProgress) => {
1150
+ const bounded = Math.max(0, Math.min(100, progress.percent));
1151
+ // Map pipeline-local progress into the global stop-session range.
1152
+ const mappedPercent = 72 + bounded * 0.2; // 72% -> 92%
1153
+ emitProcessingProgress(mappedPercent, progress.step);
1154
+ },
1155
+ });
1156
+
1157
+ console.log(
1158
+ `[Main:stopSession] Step 5/6 complete: post-processing took ${Date.now() - postProcessStartedAt}ms, ` +
1159
+ `${postProcessResult?.transcriptSegments.length ?? 0} segments, ` +
1160
+ `${postProcessResult?.extractedFrames.length ?? 0} frames extracted`
1161
+ );
1162
+
1163
+ // Notify renderer that post-processing is complete
1164
+ mainWindow?.webContents.send(IPC_CHANNELS.PROCESSING_COMPLETE, postProcessResult);
1165
+ } catch (postProcessError) {
1166
+ console.warn('[Main:stopSession] Step 5/6 FAILED: Post-processing pipeline error, continuing with basic output:', postProcessError);
1167
+ // Non-fatal: we still have the basic markdown report from the AI/rule-based pipeline
1168
+ } finally {
1169
+ postProcessDurationMs = Date.now() - postProcessStartedAt;
1170
+ }
1171
+ } else {
1172
+ console.log('[Main:stopSession] Step 5/6 skipped: no audio or recording artifacts available');
1173
+ }
1174
+ emitProcessingProgress(93, 'generating-report');
1175
+
1176
+ console.log('[Main:stopSession] Step 6/6: Finalizing report and copying to clipboard...');
1177
+ if (postProcessResult?.extractedFrames?.length) {
1178
+ await appendExtractedFramesToReport(
1179
+ saveResult.markdownPath,
1180
+ postProcessResult.extractedFrames
1181
+ ).catch((error) => {
1182
+ console.warn('[Main] Failed to append extracted frame links to report:', error);
1183
+ });
1184
+ await syncExtractedFrameMetadata(
1185
+ saveResult.sessionDir,
1186
+ postProcessResult.extractedFrames.length
1187
+ );
1188
+ await syncExtractedFrameSummary(
1189
+ saveResult.sessionDir,
1190
+ postProcessResult.extractedFrames.length
1191
+ );
1192
+ }
1193
+
1194
+ const markdownForPayload = await fs
1195
+ .readFile(saveResult.markdownPath, 'utf-8')
1196
+ .catch(() => document.content);
1197
+
1198
+ // Update progress: copying to clipboard (90%)
1199
+ windowsTaskbar?.setProgress(0.9);
1200
+ emitProcessingProgress(96, 'complete');
1201
+
1202
+ // Copy markdown report path to clipboard (the bridge into AI agents)
1203
+ const clipboardCopied = await copyReportPathToClipboard(saveResult.markdownPath);
1204
+
1205
+ // Complete progress and flash taskbar
1206
+ windowsTaskbar?.setProgress(1);
1207
+ windowsTaskbar?.flashFrame(3);
1208
+ emitProcessingProgress(99, 'complete');
1209
+
1210
+ // Clear progress after a brief delay
1211
+ setTimeout(() => {
1212
+ windowsTaskbar?.clearProgress();
1213
+ }, 1000);
1214
+
1215
+ // Build the review session for the SessionReview component
1216
+ const reviewSession = adaptSessionForMarkdown(session);
1217
+
1218
+ await writeProcessingTrace(saveResult.sessionDir, {
1219
+ reportPath: saveResult.markdownPath,
1220
+ totalMs: Date.now() - stopStartedAt,
1221
+ aiMs: aiDurationMs,
1222
+ saveMs: saveDurationMs,
1223
+ postProcessMs: postProcessDurationMs,
1224
+ audioBytes: audioArtifact?.bytesWritten ?? 0,
1225
+ recordingBytes: recordingArtifact?.bytesWritten ?? 0,
1226
+ transcriptBufferEvents: session.transcriptBuffer.length,
1227
+ providedTranscriptSegments: providedTranscriptSegments.length,
1228
+ aiFrameHints: aiFrameHintCount,
1229
+ postProcessSegments: postProcessResult?.transcriptSegments.length ?? 0,
1230
+ extractedFrames: postProcessResult?.extractedFrames.length ?? 0,
1231
+ aiTier,
1232
+ aiEnhanced,
1233
+ aiFallbackReason,
1234
+ completedAt: new Date().toISOString(),
1235
+ }).catch((error) => {
1236
+ console.warn('[Main] Failed to write processing trace:', error);
1237
+ });
1238
+
1239
+ const totalDurationMs = Date.now() - stopStartedAt;
1240
+ console.log(
1241
+ `[Main:stopSession] All steps complete in ${totalDurationMs}ms ` +
1242
+ `(AI: ${aiDurationMs}ms, save: ${saveDurationMs}ms, postProcess: ${postProcessDurationMs}ms). ` +
1243
+ `Report: ${saveResult.markdownPath}`
1244
+ );
1245
+
1246
+ // Notify renderer only after final post-processing/trace bookkeeping is finished.
1247
+ mainWindow?.webContents.send(IPC_CHANNELS.SESSION_COMPLETE, serializeSession(session));
1248
+ mainWindow?.webContents.send(IPC_CHANNELS.OUTPUT_READY, {
1249
+ markdown: markdownForPayload,
1250
+ sessionId: session.id,
1251
+ path: saveResult.markdownPath,
1252
+ reportPath: saveResult.markdownPath,
1253
+ sessionDir: saveResult.sessionDir,
1254
+ recordingPath: recordingArtifact?.path,
1255
+ audioPath: audioArtifact?.path,
1256
+ audioDurationMs: audioArtifact?.durationMs,
1257
+ videoStartTime: recordingArtifact?.startTime,
1258
+ reviewSession,
1259
+ });
1260
+
1261
+ // Show completion notification only after trace/write pipeline is done.
1262
+ showSuccessNotification(
1263
+ 'Feedback Captured!',
1264
+ clipboardCopied
1265
+ ? `${session.feedbackItems.length} items saved. Report path copied to clipboard.`
1266
+ : `${session.feedbackItems.length} items saved. Clipboard copy failed, use Copy Path in the app.`
1267
+ );
1268
+
1269
+ return {
1270
+ success: true,
1271
+ session: serializeSession(session),
1272
+ reportPath: saveResult.markdownPath,
1273
+ };
1274
+ } catch (error) {
1275
+ if (stoppedSessionId) {
1276
+ await cleanupRecordingArtifacts(stoppedSessionId);
1277
+ }
1278
+ stopStopPhaseTicker();
1279
+ sessionController.clearCapturedAudio();
1280
+ console.error('[Main] Failed to stop session:', error);
1281
+ windowsTaskbar?.clearProgress();
1282
+ const message = error instanceof Error ? error.message : 'Unknown error';
1283
+ return {
1284
+ success: false,
1285
+ error: message,
1286
+ };
1287
+ }
1288
+ }
1289
+
1290
+ /**
1291
+ * Cancel session without saving
1292
+ */
1293
+ function cancelSession(): { success: boolean } {
1294
+ const currentSessionId = sessionController.getSession()?.id;
1295
+ sessionController.cancel();
1296
+ crashRecovery.stopTracking();
1297
+
1298
+ if (currentSessionId) {
1299
+ void finalizeScreenRecording(currentSessionId).then(async (artifact) => {
1300
+ if (artifact?.tempPath) {
1301
+ await fs.unlink(artifact.tempPath).catch(() => {
1302
+ // Best-effort cleanup for canceled session recordings.
1303
+ });
1304
+ }
1305
+ deleteFinalizedRecording(currentSessionId);
1306
+ });
1307
+ }
1308
+
1309
+ return { success: true };
1310
+ }
1311
+
1312
+ // =============================================================================
1313
+ // IPC Handlers Setup (delegated to src/main/ipc/ modules)
1314
+ // =============================================================================
1315
+
1316
+ function setupIPC(): void {
1317
+ registerAllHandlers(
1318
+ {
1319
+ getMainWindow: () => mainWindow,
1320
+ getPopover: () => popover,
1321
+ getSettingsManager: () => settingsManager,
1322
+ getWindowsTaskbar: () => windowsTaskbar,
1323
+ getHasCompletedOnboarding: () => hasCompletedOnboarding,
1324
+ setHasCompletedOnboarding: (value: boolean) => { hasCompletedOnboarding = value; },
1325
+ },
1326
+ {
1327
+ startSession,
1328
+ stopSession,
1329
+ pauseSession,
1330
+ resumeSession,
1331
+ cancelSession,
1332
+ serializeSession,
1333
+ checkPermission,
1334
+ requestPermission,
1335
+ },
1336
+ );
1337
+ }
1338
+
1339
+ // =============================================================================
1340
+ // Permission Helpers
1341
+ // =============================================================================
1342
+
1343
+ async function checkPermission(type: PermissionType): Promise<boolean> {
1344
+ return permissionManager.isGranted(type);
1345
+ }
1346
+
1347
+ async function requestPermission(type: PermissionType): Promise<boolean> {
1348
+ return permissionManager.requestPermission(type);
1349
+ }
1350
+
1351
+ /**
1352
+ * Check all permissions on startup and show dialog if any are missing
1353
+ * This runs after the window is ready to ensure dialogs are properly parented
1354
+ */
1355
+ async function checkStartupPermissions(): Promise<void> {
1356
+ if (process.platform !== 'darwin') {
1357
+ // Only macOS has these system-level permissions
1358
+ return;
1359
+ }
1360
+
1361
+ const initial = await permissionManager.checkAllPermissions();
1362
+
1363
+ // On first launch, proactively trigger macOS permission prompts for not-determined states.
1364
+ if (initial.state.microphone === 'not-determined') {
1365
+ await requestPermission('microphone');
1366
+ }
1367
+ if (initial.state.screen === 'not-determined') {
1368
+ await requestPermission('screen');
1369
+ }
1370
+
1371
+ const result = await permissionManager.checkAllPermissions();
1372
+
1373
+ if (!result.allGranted && result.missing.length > 0) {
1374
+ // Log which permissions are missing
1375
+ errorHandler.log('warn', 'Missing required permissions on startup', {
1376
+ component: 'Main',
1377
+ operation: 'checkStartupPermissions',
1378
+ data: {
1379
+ missing: result.missing,
1380
+ state: result.state,
1381
+ },
1382
+ });
1383
+
1384
+ // Show guidance dialog for users who already finished onboarding.
1385
+ // New users will continue through onboarding guidance.
1386
+ if (hasCompletedOnboarding) {
1387
+ // Avoid blocking startup on a modal dialog; show guidance asynchronously.
1388
+ setTimeout(() => {
1389
+ void permissionManager.showStartupPermissionDialog(result.missing).catch((error) => {
1390
+ console.warn('[Main] Startup permission dialog failed:', error);
1391
+ });
1392
+ }, 500);
1393
+ }
1394
+ }
1395
+ }
1396
+
1397
+ // =============================================================================
1398
+ // Session Serialization Helper
1399
+ // =============================================================================
1400
+
1401
+ function serializeSession(session: Session): SessionPayload {
1402
+ return {
1403
+ id: session.id,
1404
+ startTime: session.startTime,
1405
+ endTime: session.endTime,
1406
+ state: session.state,
1407
+ sourceId: session.sourceId,
1408
+ feedbackItems: session.feedbackItems.map((item) => ({
1409
+ id: item.id,
1410
+ timestamp: item.timestamp,
1411
+ text: item.text,
1412
+ confidence: item.confidence,
1413
+ hasScreenshot: false, // Screenshots are now extracted in post-processing
1414
+ })),
1415
+ metadata: session.metadata,
1416
+ };
1417
+ }
1418
+
1419
+ // =============================================================================
1420
+ // App Lifecycle
1421
+ // =============================================================================
1422
+
1423
+ // Prevent multiple instances
1424
+ const gotTheLock = app.requestSingleInstanceLock();
1425
+
1426
+ if (!gotTheLock) {
1427
+ console.log('[Main] Another instance is running, quitting...');
1428
+ app.quit();
1429
+ } else {
1430
+ app.on('second-instance', () => {
1431
+ showWindow();
1432
+ });
1433
+ }
1434
+
1435
+ app.whenReady().then(async () => {
1436
+ console.log('[Main] App ready, starting initialization...');
1437
+
1438
+ // 1. Initialize error handler first (for crash recovery)
1439
+ await errorHandler.initialize();
1440
+
1441
+ // 1b. Initialize crash recovery manager
1442
+ await crashRecovery.initialize();
1443
+ console.log('[Main] Crash recovery initialized');
1444
+
1445
+ // 2. Initialize settings manager
1446
+ settingsManager = new SettingsManager();
1447
+ console.log('[Main] Settings loaded');
1448
+
1449
+ teardownSettingsListeners.forEach((teardown) => teardown());
1450
+ teardownSettingsListeners = [];
1451
+ teardownSettingsListeners.push(
1452
+ settingsManager.onChange((key, newValue) => {
1453
+ if (key === 'checkForUpdates') {
1454
+ autoUpdaterManager.setAutoCheckEnabled(Boolean(newValue));
1455
+ }
1456
+ }),
1457
+ );
1458
+
1459
+ // 3. Determine onboarding readiness from persisted flag or BYOK keys + transcription path
1460
+ const [hasOpenAiKey, hasAnthropicKey] = await Promise.all([
1461
+ settingsManager.hasApiKey('openai'),
1462
+ settingsManager.hasApiKey('anthropic'),
1463
+ ]);
1464
+ const hasLocalWhisperModel = modelDownloadManager.hasAnyModel();
1465
+ const hasTranscriptionPath = hasOpenAiKey || hasLocalWhisperModel;
1466
+ hasCompletedOnboarding = settingsManager.get('hasCompletedOnboarding') || (hasAnthropicKey && hasTranscriptionPath);
1467
+
1468
+ // 5. Initialize session controller
1469
+ await sessionController.initialize();
1470
+ console.log('[Main] Session controller initialized');
1471
+
1472
+ // 7. Initialize tray manager FIRST (needed for popover positioning)
1473
+ trayManager.initialize();
1474
+ console.log('[Main] Tray manager initialized');
1475
+
1476
+ // 8. Create popover window (menu bar native UI)
1477
+ const tray = trayManager.getTray();
1478
+ if (tray) {
1479
+ popover = new PopoverManager({
1480
+ width: POPOVER_SIZES.idle.width,
1481
+ height: POPOVER_SIZES.idle.height,
1482
+ tray: tray,
1483
+ });
1484
+
1485
+ const popoverWindow = popover.create();
1486
+ mainWindow = popoverWindow; // Assign to mainWindow for compatibility
1487
+ sessionController.setMainWindow(popoverWindow);
1488
+
1489
+ attachRendererDiagnostics(popoverWindow, 'Popover');
1490
+ void loadRendererIntoWindow(popoverWindow, 'Popover');
1491
+
1492
+ // Check if onboarding needed after window is ready
1493
+ popoverWindow.once('ready-to-show', () => {
1494
+ console.log('[Main] Popover ready to show');
1495
+ if (!hasCompletedOnboarding) {
1496
+ popoverWindow.webContents.send(IPC_CHANNELS.SHOW_ONBOARDING);
1497
+ }
1498
+ });
1499
+
1500
+ console.log('[Main] Popover window created');
1501
+ } else {
1502
+ // Fallback to regular window if tray not available
1503
+ createWindow();
1504
+ console.log('[Main] Fallback: Regular window created (no tray)');
1505
+ }
1506
+
1507
+ wireAudioTelemetry();
1508
+
1509
+ // Set error handler main window
1510
+ errorHandler.setMainWindow(mainWindow!);
1511
+
1512
+ // Set crash recovery main window
1513
+ crashRecovery.setMainWindow(mainWindow!);
1514
+
1515
+ // Set permission manager main window
1516
+ permissionManager.setMainWindow(mainWindow!);
1517
+
1518
+ // 9. Wire up tray click to toggle popover
1519
+ trayManager.onClick(handleTrayClick);
1520
+ trayManager.onSettingsClick(handleSettingsClick);
1521
+
1522
+ // 8b. Initialize menu manager (native macOS menu bar)
1523
+ menuManager.initialize(mainWindow!);
1524
+ menuManager.onAction((action, data) => {
1525
+ handleMenuAction(action, data);
1526
+ });
1527
+ // Load recent sessions into menu
1528
+ const recentSessions = sessionController.getRecentSessions();
1529
+ menuManager.setRecentSessions(
1530
+ recentSessions.map((s) => ({
1531
+ id: s.id,
1532
+ name: s.metadata?.sourceName || 'Feedback Session',
1533
+ path: s.id, // We use ID as path for now
1534
+ date: new Date(s.startTime),
1535
+ }))
1536
+ );
1537
+ console.log('[Main] Menu manager initialized');
1538
+
1539
+ // 8c. Initialize Windows taskbar (Windows only)
1540
+ if (process.platform === 'win32') {
1541
+ windowsTaskbar = createWindowsTaskbar(mainWindow!);
1542
+ windowsTaskbar.setActionCallbacks({
1543
+ onRecord: () => handleToggleRecording(),
1544
+ onStop: () => stopSession(),
1545
+ onScreenshot: () => handleManualScreenshot(),
1546
+ onSettings: () => handleSettingsClick(),
1547
+ });
1548
+ windowsTaskbar.initialize();
1549
+ // Update jump list with recent sessions
1550
+ windowsTaskbar.updateRecentSessions(
1551
+ recentSessions.map((s) => ({
1552
+ id: s.id,
1553
+ name: s.metadata?.sourceName || 'Feedback Session',
1554
+ path: s.id,
1555
+ date: new Date(s.startTime),
1556
+ }))
1557
+ );
1558
+ console.log('[Main] Windows taskbar initialized');
1559
+ }
1560
+
1561
+ // 9. Initialize hotkeys
1562
+ initializeHotkeys();
1563
+
1564
+ // 10. Setup IPC handlers
1565
+ setupIPC();
1566
+
1567
+ // 11. Configure session controller event callbacks
1568
+ sessionController.setEventCallbacks({
1569
+ onStateChange: handleSessionStateChange,
1570
+ onFeedbackItem: handleFeedbackItem,
1571
+ onError: handleSessionError,
1572
+ });
1573
+
1574
+ // 12. Initialize auto-updater (only in production)
1575
+ if (process.env.NODE_ENV !== 'development') {
1576
+ autoUpdaterManager.setAutoCheckEnabled(settingsManager.get('checkForUpdates'));
1577
+ autoUpdaterManager.initialize(mainWindow!);
1578
+ console.log('[Main] Auto-updater initialized');
1579
+ } else {
1580
+ console.log('[Main] Auto-updater skipped (development mode)');
1581
+ }
1582
+
1583
+ // 13. Check permissions on startup (macOS only)
1584
+ // Delay slightly to ensure window is fully ready
1585
+ setTimeout(async () => {
1586
+ await checkStartupPermissions();
1587
+ console.log('[Main] Startup permission check complete');
1588
+ }, 1000);
1589
+
1590
+ // Handle macOS dock click (fallback, dock is hidden in menu bar mode)
1591
+ app.on('activate', () => {
1592
+ // In menu bar mode, show the popover
1593
+ if (popover) {
1594
+ popover.show();
1595
+ return;
1596
+ }
1597
+
1598
+ // Fallback for non-popover mode
1599
+ if (BrowserWindow.getAllWindows().length === 0) {
1600
+ createWindow();
1601
+ } else {
1602
+ showWindow();
1603
+ }
1604
+ });
1605
+
1606
+ console.log('[Main] markupr initialization complete');
1607
+ });
1608
+
1609
+ // Handle all windows closed
1610
+ app.on('window-all-closed', () => {
1611
+ // On macOS, keep the app running in the tray
1612
+ if (process.platform !== 'darwin') {
1613
+ app.quit();
1614
+ }
1615
+ });
1616
+
1617
+ // Handle before quit
1618
+ app.on('before-quit', () => {
1619
+ isQuitting = true;
1620
+ });
1621
+
1622
+ // Handle app quit
1623
+ app.on('will-quit', async () => {
1624
+ console.log('[Main] App quitting, cleaning up...');
1625
+
1626
+ // Stop any active session
1627
+ if (sessionController.getState() === 'recording') {
1628
+ sessionController.cancel();
1629
+ }
1630
+
1631
+ // Best-effort cleanup of temporary recording artifacts.
1632
+ for (const [sessionId] of getActiveScreenRecordings()) {
1633
+ const artifact = await finalizeScreenRecording(sessionId).catch(() => null);
1634
+ if (artifact?.tempPath) {
1635
+ await fs.unlink(artifact.tempPath).catch(() => {});
1636
+ }
1637
+ deleteFinalizedRecording(sessionId);
1638
+ }
1639
+ for (const artifact of getFinalizedScreenRecordings().values()) {
1640
+ await fs.unlink(artifact.tempPath).catch(() => {});
1641
+ }
1642
+ getFinalizedScreenRecordings().clear();
1643
+
1644
+ // Cleanup services
1645
+ teardownAudioTelemetry.forEach((teardown) => teardown());
1646
+ teardownAudioTelemetry = [];
1647
+ teardownSettingsListeners.forEach((teardown) => teardown());
1648
+ teardownSettingsListeners = [];
1649
+ hotkeyManager.unregisterAll();
1650
+ popover?.destroy();
1651
+ trayManager.destroy();
1652
+ menuManager.destroy();
1653
+ windowsTaskbar?.destroy();
1654
+ sessionController.destroy();
1655
+ autoUpdaterManager.destroy();
1656
+ crashRecovery.destroy();
1657
+
1658
+ // Clean up error handler
1659
+ await errorHandler.destroy();
1660
+
1661
+ console.log('[Main] Cleanup complete');
1662
+ });
1663
+
1664
+ // Handle uncaught exceptions
1665
+ process.on('uncaughtException', (error) => {
1666
+ if (isIgnorableStdioError(error)) {
1667
+ return;
1668
+ }
1669
+
1670
+ console.error('[Main] Uncaught exception:', error);
1671
+ try {
1672
+ showErrorNotification('markupr Error', error.message);
1673
+ } catch {
1674
+ // Ignore notification errors
1675
+ }
1676
+ });
1677
+
1678
+ // Handle unhandled promise rejections
1679
+ process.on('unhandledRejection', (reason) => {
1680
+ if (isIgnorableStdioError(reason)) {
1681
+ return;
1682
+ }
1683
+
1684
+ console.error('[Main] Unhandled rejection:', reason);
1685
+ });
1686
+
1687
+ // Export for testing
1688
+ export {
1689
+ createWindow,
1690
+ startSession,
1691
+ stopSession,
1692
+ showWindow,
1693
+ };