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,1465 @@
1
+ /**
2
+ * SessionController - Core Orchestrator for markupr
3
+ *
4
+ * Implements a bulletproof finite state machine for session lifecycle:
5
+ * idle -> starting -> recording -> stopping -> processing -> complete
6
+ *
7
+ * Responsibilities:
8
+ * - Coordinate all services (audio, video recording)
9
+ * - Manage session state (crash recovery delegated to CrashRecoveryManager)
10
+ * - Watchdog timer to prevent stuck states
11
+ * - Run PostProcessor pipeline after recording stops
12
+ * - Emit state changes and processing progress to renderer via IPC
13
+ */
14
+
15
+ import Store from 'electron-store';
16
+ import { BrowserWindow } from 'electron';
17
+ import { randomUUID } from 'crypto';
18
+ import { audioCapture, type AudioChunk } from './audio/AudioCapture';
19
+ import type { TranscriptEvent } from './transcription/types';
20
+ import { recoverTranscript, normalizeTranscriptTimestamp } from './transcription/TranscriptionRecoveryService';
21
+ import { IPC_CHANNELS, type SessionState, type SessionMetadata } from '../shared/types';
22
+ import { errorHandler } from './ErrorHandler';
23
+ import { type PostProcessResult, type PostProcessProgress } from './pipeline';
24
+
25
+ // =============================================================================
26
+ // Types - Bulletproof State Machine
27
+ // =============================================================================
28
+
29
+ // SessionState is imported from '../shared/types' (single source of truth)
30
+
31
+ /**
32
+ * State timeout configuration (in milliseconds).
33
+ * Every state except 'idle' has a maximum duration.
34
+ */
35
+ export const STATE_TIMEOUTS: Record<SessionState, number | null> = {
36
+ idle: null, // Infinite - waits for user action
37
+ starting: 5_000, // 5 seconds to initialize
38
+ recording: 30 * 60_000, // 30 minutes max recording
39
+ stopping: 3_000, // 3 seconds to stop services
40
+ processing: 5 * 60_000, // 5 minutes for post-processing pipeline
41
+ complete: 5 * 60_000, // 5 minutes for post-processing to finish
42
+ error: 5_000, // 5 seconds to show error
43
+ } as const;
44
+
45
+ /**
46
+ * Recording duration warnings and limits
47
+ */
48
+ export const RECORDING_LIMITS = {
49
+ WARNING_DURATION_MS: 25 * 60_000, // 25 minutes - show warning
50
+ MAX_DURATION_MS: 30 * 60_000, // 30 minutes - force stop
51
+ } as const;
52
+
53
+ /**
54
+ * @deprecated Screenshot capture during recording has been removed.
55
+ * Frame extraction now happens in the post-processing pipeline.
56
+ * This interface is kept for backward compatibility with downstream consumers.
57
+ */
58
+ export interface Screenshot {
59
+ id: string;
60
+ timestamp: number;
61
+ buffer: Buffer;
62
+ width: number;
63
+ height: number;
64
+ base64?: string;
65
+ trigger?: string;
66
+ }
67
+
68
+ export interface FeedbackItem {
69
+ id: string;
70
+ timestamp: number;
71
+ text: string;
72
+ confidence: number;
73
+ /**
74
+ * @deprecated Screenshots are no longer captured during recording.
75
+ * Extracted frames are available via PostProcessResult after processing.
76
+ */
77
+ screenshot?: Screenshot;
78
+ }
79
+
80
+ // SessionMetadata is imported from '../shared/types' (single source of truth)
81
+
82
+ export interface Session {
83
+ id: string;
84
+ startTime: number;
85
+ endTime?: number;
86
+ state: SessionState;
87
+ sourceId: string;
88
+ feedbackItems: FeedbackItem[];
89
+ transcriptBuffer: TranscriptEvent[];
90
+ /**
91
+ * @deprecated Screenshots are no longer captured during recording.
92
+ * Kept for backward compatibility; always an empty array.
93
+ */
94
+ screenshotBuffer: Screenshot[];
95
+ metadata: SessionMetadata;
96
+ }
97
+
98
+ export interface SessionStatus {
99
+ state: SessionState;
100
+ duration: number;
101
+ feedbackCount: number;
102
+ screenshotCount: number;
103
+ isPaused: boolean;
104
+ /** Post-processing progress (only set when state === 'processing') */
105
+ processingProgress?: PostProcessProgress;
106
+ }
107
+
108
+ export interface SessionControllerEvents {
109
+ onStateChange: (state: SessionState, session: Session | null) => void;
110
+ onFeedbackItem: (item: FeedbackItem) => void;
111
+ onError: (error: Error) => void;
112
+ }
113
+
114
+ /**
115
+ * Valid state transitions.
116
+ * Every state has a path back to 'idle' for recovery.
117
+ */
118
+ const STATE_TRANSITIONS: Record<SessionState, SessionState[]> = {
119
+ idle: ['starting'],
120
+ starting: ['recording', 'error', 'idle'], // success, error, or cancel
121
+ recording: ['stopping', 'error', 'idle'], // stop, error, or cancel
122
+ stopping: ['processing', 'error', 'idle'], // success, error, or cancel
123
+ processing: ['complete', 'error', 'idle'], // success, error, or timeout
124
+ complete: ['idle'], // only back to idle
125
+ error: ['idle'], // only back to idle
126
+ };
127
+
128
+ // =============================================================================
129
+ // Persistence Store Schema
130
+ // =============================================================================
131
+
132
+ // Session data for persistence (without Buffer objects)
133
+ interface PersistedSession {
134
+ id: string;
135
+ startTime: number;
136
+ endTime?: number;
137
+ state: SessionState;
138
+ sourceId: string;
139
+ feedbackItemCount: number;
140
+ metadata: SessionMetadata;
141
+ }
142
+
143
+ interface StoreSchema {
144
+ currentSession: PersistedSession | null;
145
+ recentSessions: PersistedSession[];
146
+ }
147
+
148
+ const store = new Store<StoreSchema>({
149
+ name: 'markupr-sessions',
150
+ defaults: {
151
+ currentSession: null,
152
+ recentSessions: [],
153
+ },
154
+ // Clear on corruption
155
+ clearInvalidConfig: true,
156
+ });
157
+
158
+ // =============================================================================
159
+ // SessionController Class
160
+ // =============================================================================
161
+
162
+ export class SessionController {
163
+ // Core state
164
+ private state: SessionState = 'idle';
165
+ private isPaused = false;
166
+ private pausedAtMs: number | null = null;
167
+ private accumulatedPausedMs = 0;
168
+ private captureCount = 0;
169
+ private session: Session | null = null;
170
+ private events: SessionControllerEvents | null = null;
171
+ private mainWindow: BrowserWindow | null = null;
172
+
173
+ // Service references (using actual implementations)
174
+ private audioCaptureService: typeof audioCapture;
175
+
176
+ // Cleanup functions for event subscriptions
177
+ private cleanupFunctions: Array<() => void> = [];
178
+
179
+ // Timers
180
+ private autoSaveTimer: NodeJS.Timeout | null = null;
181
+ private durationTimer: NodeJS.Timeout | null = null;
182
+ private watchdogTimer: NodeJS.Timeout | null = null;
183
+
184
+ // Watchdog state
185
+ private stateEnteredAt: number = Date.now();
186
+ private recordingWarningShown: boolean = false;
187
+
188
+ // Post-processing result (available after processing completes)
189
+ private postProcessResult: PostProcessResult | null = null;
190
+
191
+ // Current processing progress (for status reporting)
192
+ private currentProcessingProgress: PostProcessProgress | null = null;
193
+
194
+ // Configuration constants
195
+ private readonly AUTO_SAVE_INTERVAL_MS = 5000; // 5 seconds (per spec)
196
+ private readonly WATCHDOG_CHECK_INTERVAL_MS = 1000; // 1 second
197
+ private readonly MAX_RECENT_SESSIONS = 10;
198
+ private readonly MAX_TRANSCRIPT_BUFFER_EVENTS = 2000;
199
+
200
+ private getActiveDurationMs(nowMs: number = Date.now()): number {
201
+ if (!this.session) {
202
+ return 0;
203
+ }
204
+
205
+ const activePauseWindowMs =
206
+ this.isPaused && this.pausedAtMs !== null
207
+ ? Math.max(0, nowMs - this.pausedAtMs)
208
+ : 0;
209
+
210
+ return Math.max(
211
+ 0,
212
+ nowMs - this.session.startTime - this.accumulatedPausedMs - activePauseWindowMs
213
+ );
214
+ }
215
+
216
+ private closeActivePauseWindow(nowMs: number = Date.now()): void {
217
+ if (this.pausedAtMs === null) {
218
+ return;
219
+ }
220
+ this.accumulatedPausedMs += Math.max(0, nowMs - this.pausedAtMs);
221
+ this.pausedAtMs = null;
222
+ }
223
+
224
+ private resetSessionRuntimeState(): void {
225
+ this.isPaused = false;
226
+ this.pausedAtMs = null;
227
+ this.accumulatedPausedMs = 0;
228
+ this.captureCount = 0;
229
+ }
230
+
231
+ constructor() {
232
+ // Use singleton instances
233
+ this.audioCaptureService = audioCapture;
234
+ }
235
+
236
+ // ===========================================================================
237
+ // Timeout Utilities
238
+ // ===========================================================================
239
+
240
+ /**
241
+ * Wraps an async operation with a timeout.
242
+ * If the operation takes longer than timeoutMs, returns the fallback value.
243
+ *
244
+ * @param operation - The promise to wrap
245
+ * @param timeoutMs - Maximum time to wait in milliseconds
246
+ * @param fallback - Value to return if timeout occurs
247
+ * @param operationName - Name for logging purposes
248
+ * @returns Promise resolving to either the operation result or the fallback
249
+ */
250
+ private async withTimeout<T>(
251
+ operation: Promise<T>,
252
+ timeoutMs: number,
253
+ fallback: T,
254
+ operationName: string = 'operation'
255
+ ): Promise<T> {
256
+ let timeoutId: NodeJS.Timeout | undefined;
257
+
258
+ const timeoutPromise = new Promise<T>((_, reject) => {
259
+ timeoutId = setTimeout(() => {
260
+ reject(new Error(`${operationName} timed out after ${timeoutMs}ms`));
261
+ }, timeoutMs);
262
+ });
263
+
264
+ try {
265
+ const result = await Promise.race([operation, timeoutPromise]);
266
+ if (timeoutId) clearTimeout(timeoutId);
267
+ return result;
268
+ } catch (error) {
269
+ if (timeoutId) clearTimeout(timeoutId);
270
+ console.warn(`[SessionController] ${operationName} timeout/error, using fallback:`, error);
271
+ return fallback;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Wraps a synchronous function that may block, converting it to async with timeout.
277
+ * Useful for wrapping service.stop() calls that don't return promises.
278
+ */
279
+ private async withTimeoutSync(
280
+ fn: () => void,
281
+ timeoutMs: number,
282
+ operationName: string = 'operation'
283
+ ): Promise<void> {
284
+ return this.withTimeout(
285
+ new Promise<void>((resolve) => {
286
+ try {
287
+ fn();
288
+ resolve();
289
+ } catch (error) {
290
+ console.warn(`[SessionController] ${operationName} threw:`, error);
291
+ resolve(); // Still resolve - we don't want to block
292
+ }
293
+ }),
294
+ timeoutMs,
295
+ undefined,
296
+ operationName
297
+ );
298
+ }
299
+
300
+ /**
301
+ * Initialize the SessionController.
302
+ * Crash recovery is handled by CrashRecoveryManager (single authority).
303
+ */
304
+ async initialize(): Promise<void> {
305
+ console.log('[SessionController] Initializing...');
306
+
307
+ // Start watchdog immediately
308
+ this.startWatchdog();
309
+
310
+ console.log('[SessionController] Initialization complete');
311
+ }
312
+
313
+ /**
314
+ * Set the main window for IPC communication
315
+ */
316
+ setMainWindow(window: BrowserWindow): void {
317
+ this.mainWindow = window;
318
+ // Also set on audio capture service (it needs window for renderer communication)
319
+ this.audioCaptureService.setMainWindow(window);
320
+ }
321
+
322
+ /**
323
+ * Set event callbacks
324
+ */
325
+ setEventCallbacks(events: SessionControllerEvents): void {
326
+ this.events = events;
327
+ }
328
+
329
+ /**
330
+ * Configure transcription service with API key
331
+ * Note: TierManager handles API key configuration internally via settings
332
+ * This method is kept for backward compatibility but is now a no-op
333
+ */
334
+ configureTranscription(_apiKey: string): void {
335
+ // TierManager reads API key from settings automatically
336
+ // No action needed here
337
+ }
338
+
339
+ // ===========================================================================
340
+ // State Machine
341
+ // ===========================================================================
342
+
343
+ /**
344
+ * Transition to a new state with validation.
345
+ * Updates state entry time for watchdog tracking.
346
+ */
347
+ private transition(newState: SessionState): boolean {
348
+ const validTransitions = STATE_TRANSITIONS[this.state];
349
+
350
+ if (!validTransitions.includes(newState)) {
351
+ console.error(
352
+ `[SessionController] Invalid state transition: ${this.state} -> ${newState}`
353
+ );
354
+ return false;
355
+ }
356
+
357
+ const oldState = this.state;
358
+ this.state = newState;
359
+ this.stateEnteredAt = Date.now();
360
+ this.recordingWarningShown = false; // Reset for new state
361
+
362
+ // Update session state if exists
363
+ if (this.session) {
364
+ this.session.state = newState;
365
+ }
366
+
367
+ console.log(`[SessionController] State: ${oldState} -> ${newState}`);
368
+
369
+ // Notify listeners
370
+ this.emitStateChange();
371
+
372
+ return true;
373
+ }
374
+
375
+ /**
376
+ * Get current state
377
+ */
378
+ getState(): SessionState {
379
+ return this.state;
380
+ }
381
+
382
+ // ===========================================================================
383
+ // Session Lifecycle
384
+ // ===========================================================================
385
+
386
+ /**
387
+ * Start a new recording session.
388
+ * Transitions: idle -> starting -> recording
389
+ */
390
+ async start(sourceId: string, sourceName?: string): Promise<void> {
391
+ if (this.state !== 'idle') {
392
+ throw new Error(`Cannot start session from state: ${this.state}`);
393
+ }
394
+
395
+ console.log(`[SessionController] Starting session for source: ${sourceId}`);
396
+
397
+ // Transition to starting state FIRST
398
+ if (!this.transition('starting')) {
399
+ throw new Error('Failed to transition to starting state');
400
+ }
401
+
402
+ // Reset counters
403
+ this.audioChunkCount = 0;
404
+ this.resetSessionRuntimeState();
405
+ this.postProcessResult = null;
406
+ this.currentProcessingProgress = null;
407
+
408
+ // Create new session
409
+ this.session = {
410
+ id: randomUUID(),
411
+ startTime: Date.now(),
412
+ state: 'starting',
413
+ sourceId,
414
+ feedbackItems: [],
415
+ transcriptBuffer: [],
416
+ screenshotBuffer: [],
417
+ metadata: {
418
+ sourceId,
419
+ sourceName,
420
+ },
421
+ };
422
+
423
+ try {
424
+ // Initialize services with timeout protection
425
+ await this.initializeServicesWithTimeout();
426
+
427
+ // Transition to recording
428
+ if (!this.transition('recording')) {
429
+ throw new Error('Failed to transition to recording state');
430
+ }
431
+ this.session.state = 'recording';
432
+
433
+ // Start timers
434
+ this.startAutoSave();
435
+ this.startDurationTimer();
436
+
437
+ console.log(`[SessionController] Session started: ${this.session.id}`);
438
+ } catch (error) {
439
+ console.error('[SessionController] Failed to start services:', error);
440
+
441
+ // Cleanup on failure
442
+ await this.cleanupServicesAsync();
443
+ this.session = null;
444
+
445
+ // Transition to error state (which will auto-recover to idle)
446
+ try {
447
+ this.transition('error');
448
+ this.emitToRenderer(IPC_CHANNELS.SESSION_ERROR, {
449
+ type: 'startError',
450
+ message: error instanceof Error ? error.message : 'Failed to start session',
451
+ });
452
+ } catch {
453
+ // If transition fails, force to idle
454
+ this.transitionForced('idle');
455
+ }
456
+
457
+ throw error;
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Initialize all services with timeout protection.
463
+ * Total timeout: 5 seconds (starting state timeout).
464
+ */
465
+ private async initializeServicesWithTimeout(): Promise<void> {
466
+ const totalTimeout = STATE_TIMEOUTS.starting!;
467
+ const startTime = Date.now();
468
+
469
+ // Subscribe to audio events
470
+ const unsubAudioChunk = this.audioCaptureService.onAudioChunk((chunk) =>
471
+ this.handleAudioChunk(chunk)
472
+ );
473
+ const unsubVoiceActivity = this.audioCaptureService.onVoiceActivity((active) =>
474
+ this.handleVoiceActivity(active)
475
+ );
476
+ const unsubAudioError = this.audioCaptureService.onError((error) =>
477
+ this.handleServiceError('audio', error)
478
+ );
479
+
480
+ this.cleanupFunctions.push(unsubAudioChunk, unsubVoiceActivity, unsubAudioError);
481
+
482
+ console.log(
483
+ '[SessionController] Live transcription disabled for this workflow; using post-session transcription from captured audio.'
484
+ );
485
+
486
+ // Check if we still have time
487
+ const elapsed2 = Date.now() - startTime;
488
+ const remaining2 = Math.max(totalTimeout - elapsed2, 500);
489
+
490
+ // Start audio capture with timeout
491
+ const audioStarted = await this.withTimeout(
492
+ this.audioCaptureService.start().then(() => true),
493
+ remaining2,
494
+ false,
495
+ 'AudioCapture.start()'
496
+ );
497
+
498
+ if (!audioStarted) {
499
+ throw new Error(
500
+ 'Microphone capture failed to start. Check microphone permission and selected input device, then retry.'
501
+ );
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Stop the current session and run the post-processing pipeline.
507
+ * Transitions: recording -> stopping -> processing -> complete
508
+ */
509
+ async stop(): Promise<Session | null> {
510
+ if (this.state !== 'recording') {
511
+ console.warn(`[SessionController] Cannot stop from state: ${this.state}`);
512
+ return null;
513
+ }
514
+
515
+ if (!this.session) {
516
+ console.warn('[SessionController] No active session to stop');
517
+ return null;
518
+ }
519
+
520
+ console.log(`[SessionController] Stopping session: ${this.session.id}`);
521
+
522
+ // Transition to stopping state
523
+ if (!this.transition('stopping')) {
524
+ console.error('[SessionController] Failed to transition to stopping state');
525
+ return null;
526
+ }
527
+ this.session.state = 'stopping';
528
+
529
+ // Stop services with timeout protection
530
+ await this.withTimeout(
531
+ this.cleanupServicesAsync(),
532
+ STATE_TIMEOUTS.stopping!,
533
+ undefined,
534
+ 'cleanupServices'
535
+ );
536
+
537
+ // Transition to processing
538
+ if (!this.transition('processing')) {
539
+ console.error('[SessionController] Failed to transition to processing state');
540
+ // Force complete with partial data
541
+ this.transitionForced('complete');
542
+ }
543
+ this.session.state = 'processing';
544
+
545
+ // NOTE: The full PostProcessor pipeline (transcribe -> analyze -> extract frames)
546
+ // is NOT run here because recordingPath and audioPath are not yet available on
547
+ // session.metadata at this point. Those paths are set later in stopSession()
548
+ // (src/main/index.ts) after the recording and audio files are finalized and
549
+ // written to disk. The real PostProcessor call happens there with the actual
550
+ // file paths. Here we only attempt transcript recovery from the in-memory
551
+ // captured audio buffer as a fallback for when stop() is called directly
552
+ // (e.g., by the watchdog timer) without going through stopSession().
553
+ await this.withTimeout(
554
+ this.recoverTranscriptFromCapturedAudio(),
555
+ Math.floor(STATE_TIMEOUTS.processing! * 0.8),
556
+ undefined,
557
+ 'recoverTranscriptFromCapturedAudio'
558
+ );
559
+
560
+ // Set end time
561
+ this.session.endTime = Date.now();
562
+ this.closeActivePauseWindow(this.session.endTime);
563
+ this.isPaused = false;
564
+ this.currentProcessingProgress = null;
565
+
566
+ // Transition to complete
567
+ if (!this.transition('complete')) {
568
+ this.transitionForced('complete');
569
+ }
570
+ this.session.state = 'complete';
571
+
572
+ // Move to recent sessions
573
+ const completedSession = { ...this.session };
574
+ this.addToRecentSessions(completedSession);
575
+
576
+ // Clear current session from store
577
+ store.set('currentSession', null);
578
+
579
+ console.log(
580
+ `[SessionController] Session completed: ${completedSession.id}, ` +
581
+ `${completedSession.feedbackItems.length} feedback items`
582
+ );
583
+
584
+ return completedSession;
585
+ }
586
+
587
+ /**
588
+ * Cancel the current session without processing
589
+ */
590
+ cancel(): void {
591
+ if (this.state !== 'recording' && this.state !== 'processing' && this.state !== 'starting') {
592
+ console.warn(`[SessionController] Cannot cancel from state: ${this.state}`);
593
+ return;
594
+ }
595
+
596
+ console.log(`[SessionController] Cancelling session: ${this.session?.id}`);
597
+
598
+ // Force cleanup (don't wait for async)
599
+ this.cleanupServicesForced();
600
+ this.audioCaptureService.clearCapturedAudio();
601
+
602
+ // Clear session
603
+ this.session = null;
604
+ this.resetSessionRuntimeState();
605
+ store.set('currentSession', null);
606
+
607
+ // Force transition to idle (bypass normal validation since we're cancelling)
608
+ this.transitionForced('idle');
609
+ }
610
+
611
+ /**
612
+ * Reset controller to idle state
613
+ */
614
+ reset(): void {
615
+ this.cleanupServices();
616
+ this.session = null;
617
+ this.postProcessResult = null;
618
+ this.currentProcessingProgress = null;
619
+ this.resetSessionRuntimeState();
620
+ this.state = 'idle';
621
+ this.emitStateChange();
622
+ }
623
+
624
+ // ===========================================================================
625
+ // Status & Data Access
626
+ // ===========================================================================
627
+
628
+ /**
629
+ * Get current session status
630
+ */
631
+ getStatus(): SessionStatus {
632
+ const duration = this.getActiveDurationMs();
633
+
634
+ const status: SessionStatus = {
635
+ state: this.state,
636
+ duration,
637
+ feedbackCount: this.session?.feedbackItems.length ?? 0,
638
+ screenshotCount: this.session ? this.captureCount : 0,
639
+ isPaused: this.state === 'recording' && this.isPaused,
640
+ };
641
+
642
+ if (this.state === 'processing' && this.currentProcessingProgress) {
643
+ status.processingProgress = this.currentProcessingProgress;
644
+ }
645
+
646
+ return status;
647
+ }
648
+
649
+ isSessionPaused(): boolean {
650
+ return this.state === 'recording' && this.isPaused;
651
+ }
652
+
653
+ pause(): boolean {
654
+ if (this.state !== 'recording' || this.isPaused) {
655
+ return false;
656
+ }
657
+
658
+ this.isPaused = true;
659
+ this.pausedAtMs = Date.now();
660
+ this.audioCaptureService.setPaused(true);
661
+ this.emitToRenderer(IPC_CHANNELS.SESSION_VOICE_ACTIVITY, { active: false });
662
+ this.emitStatus();
663
+ return true;
664
+ }
665
+
666
+ resume(): boolean {
667
+ if (this.state !== 'recording' || !this.isPaused) {
668
+ return false;
669
+ }
670
+
671
+ this.closeActivePauseWindow();
672
+ this.isPaused = false;
673
+ this.audioCaptureService.setPaused(false);
674
+ this.emitStatus();
675
+ return true;
676
+ }
677
+
678
+ registerCaptureCue(
679
+ trigger: 'pause' | 'manual' | 'voice-command' = 'manual'
680
+ ): { id: string; timestamp: number; count: number; trigger: 'pause' | 'manual' | 'voice-command' } | null {
681
+ if (this.state !== 'recording' || this.isPaused || !this.session) {
682
+ return null;
683
+ }
684
+
685
+ this.captureCount += 1;
686
+ const payload = {
687
+ id: randomUUID(),
688
+ timestamp: Date.now(),
689
+ count: this.captureCount,
690
+ trigger,
691
+ };
692
+
693
+ this.emitToRenderer(IPC_CHANNELS.SCREENSHOT_CAPTURED, payload);
694
+ this.emitStatus();
695
+ return payload;
696
+ }
697
+
698
+ /**
699
+ * Get current session
700
+ */
701
+ getSession(): Session | null {
702
+ return this.session ? { ...this.session } : null;
703
+ }
704
+
705
+ /**
706
+ * Update metadata fields on the active session.
707
+ */
708
+ setSessionMetadata(updates: Partial<SessionMetadata>): boolean {
709
+ if (!this.session) {
710
+ return false;
711
+ }
712
+
713
+ this.session.metadata = {
714
+ ...this.session.metadata,
715
+ ...updates,
716
+ };
717
+
718
+ this.persistSession();
719
+ return true;
720
+ }
721
+
722
+ /**
723
+ * Export the captured microphone audio for the most recent session.
724
+ */
725
+ async exportCapturedAudio(
726
+ outputPathBase: string,
727
+ ): Promise<{ path: string; bytesWritten: number; durationMs: number; mimeType: string } | null> {
728
+ const exported = await this.audioCaptureService.exportCapturedAudio(outputPathBase);
729
+ if (!exported) {
730
+ return null;
731
+ }
732
+
733
+ return exported;
734
+ }
735
+
736
+ clearCapturedAudio(): void {
737
+ this.audioCaptureService.clearCapturedAudio();
738
+ }
739
+
740
+ /**
741
+ * Get recent completed sessions (persisted metadata only)
742
+ */
743
+ getRecentSessions(): PersistedSession[] {
744
+ return store.get('recentSessions') || [];
745
+ }
746
+
747
+ /**
748
+ * Get the post-processing result from the most recent session.
749
+ * Available after the processing state completes.
750
+ */
751
+ getPostProcessResult(): PostProcessResult | null {
752
+ return this.postProcessResult;
753
+ }
754
+
755
+ // ===========================================================================
756
+ // Feedback Item Management
757
+ // ===========================================================================
758
+
759
+ /**
760
+ * Add a feedback item manually
761
+ */
762
+ addFeedbackItem(item: Partial<FeedbackItem>): FeedbackItem {
763
+ if (!this.session) {
764
+ throw new Error('No active session');
765
+ }
766
+
767
+ const feedbackItem: FeedbackItem = {
768
+ id: randomUUID(),
769
+ timestamp: Date.now(),
770
+ text: item.text || '',
771
+ confidence: item.confidence ?? 1.0,
772
+ };
773
+
774
+ this.session.feedbackItems.push(feedbackItem);
775
+ this.emitFeedbackItem(feedbackItem);
776
+ this.persistSession();
777
+
778
+ return feedbackItem;
779
+ }
780
+
781
+ /**
782
+ * Delete a feedback item
783
+ */
784
+ deleteFeedbackItem(id: string): boolean {
785
+ if (!this.session) {
786
+ return false;
787
+ }
788
+
789
+ const index = this.session.feedbackItems.findIndex((item) => item.id === id);
790
+ if (index === -1) {
791
+ return false;
792
+ }
793
+
794
+ this.session.feedbackItems.splice(index, 1);
795
+ this.persistSession();
796
+ this.emitStateChange();
797
+
798
+ return true;
799
+ }
800
+
801
+ /**
802
+ * Update a feedback item
803
+ */
804
+ updateFeedbackItem(id: string, updates: Partial<FeedbackItem>): FeedbackItem | null {
805
+ if (!this.session) {
806
+ return null;
807
+ }
808
+
809
+ const item = this.session.feedbackItems.find((item) => item.id === id);
810
+ if (!item) {
811
+ return null;
812
+ }
813
+
814
+ Object.assign(item, updates, { id }); // Preserve ID
815
+ this.persistSession();
816
+
817
+ return item;
818
+ }
819
+
820
+ // ===========================================================================
821
+ // Service Event Handlers
822
+ // ===========================================================================
823
+
824
+ // Audio chunk counter for debug logging
825
+ private audioChunkCount: number = 0;
826
+
827
+ /**
828
+ * Handle audio chunk from microphone
829
+ */
830
+ private handleAudioChunk(_chunk: AudioChunk): void {
831
+ if (this.state !== 'recording') {
832
+ return;
833
+ }
834
+
835
+ if (this.isPaused) {
836
+ return;
837
+ }
838
+
839
+ // Log every 100 chunks (roughly every 10 seconds at 100ms chunks)
840
+ this.audioChunkCount++;
841
+ if (this.audioChunkCount % 100 === 0) {
842
+ console.log(
843
+ `[SessionController] Audio captured: ${this.audioChunkCount} chunks, ${Math.round(
844
+ this.audioChunkCount * 0.1
845
+ )}s of audio`
846
+ );
847
+ }
848
+ }
849
+
850
+ /**
851
+ * Handle voice activity changes (forward to renderer for UI feedback).
852
+ * Screenshots are no longer captured during recording; frame extraction
853
+ * happens in the post-processing pipeline after recording stops.
854
+ */
855
+ private handleVoiceActivity(active: boolean): void {
856
+ if (this.isPaused) {
857
+ this.emitToRenderer(IPC_CHANNELS.SESSION_VOICE_ACTIVITY, { active: false });
858
+ return;
859
+ }
860
+
861
+ this.emitToRenderer(IPC_CHANNELS.SESSION_VOICE_ACTIVITY, { active });
862
+ }
863
+
864
+ /**
865
+ * Run a post-session transcription recovery pass when live transcription produced no final output.
866
+ * Delegates to TranscriptionRecoveryService for the actual recovery strategies.
867
+ */
868
+ private async recoverTranscriptFromCapturedAudio(): Promise<void> {
869
+ if (!this.session) {
870
+ return;
871
+ }
872
+
873
+ const hasFinalTranscript = this.session.transcriptBuffer.some(
874
+ (entry) => entry.isFinal && entry.text.trim().length > 0,
875
+ );
876
+ if (hasFinalTranscript) {
877
+ return;
878
+ }
879
+
880
+ const sessionStartSec = this.session.startTime / 1000;
881
+ const recoveredEvents = await recoverTranscript(sessionStartSec, {
882
+ capturedAudioAsset: this.audioCaptureService.getCapturedAudioAsset(),
883
+ capturedAudioBuffer: this.audioCaptureService.getCapturedAudioBuffer(),
884
+ });
885
+
886
+ if (recoveredEvents.length > 0) {
887
+ this.session.transcriptBuffer.push(...recoveredEvents);
888
+ this.session.transcriptBuffer.sort((a, b) => a.timestamp - b.timestamp);
889
+ if (this.session.transcriptBuffer.length > this.MAX_TRANSCRIPT_BUFFER_EVENTS) {
890
+ this.session.transcriptBuffer.splice(
891
+ 0,
892
+ this.session.transcriptBuffer.length - this.MAX_TRANSCRIPT_BUFFER_EVENTS,
893
+ );
894
+ }
895
+ }
896
+ }
897
+
898
+ /**
899
+ * Handle transcript result from the active transcription source.
900
+ * Note: Live transcription is currently disabled in favor of post-processing,
901
+ * but this handler is retained for future use if streaming transcription returns.
902
+ */
903
+ private handleTranscriptResult(event: TranscriptEvent): void {
904
+ if (!this.session) {
905
+ return;
906
+ }
907
+
908
+ const sessionStartSec = this.session.startTime / 1000;
909
+ const normalizedTimestamp = normalizeTranscriptTimestamp(event.timestamp, sessionStartSec);
910
+ const normalizedEvent: TranscriptEvent = {
911
+ ...event,
912
+ timestamp: normalizedTimestamp,
913
+ };
914
+
915
+ const text = normalizedEvent.text.trim();
916
+ if (!text) {
917
+ return;
918
+ }
919
+
920
+ // Add to buffer
921
+ this.session.transcriptBuffer.push(normalizedEvent);
922
+ if (this.session.transcriptBuffer.length > this.MAX_TRANSCRIPT_BUFFER_EVENTS) {
923
+ this.session.transcriptBuffer.splice(
924
+ 0,
925
+ this.session.transcriptBuffer.length - this.MAX_TRANSCRIPT_BUFFER_EVENTS
926
+ );
927
+ }
928
+
929
+ // Emit to renderer
930
+ if (normalizedEvent.isFinal) {
931
+ console.log(`[SessionController] Final transcript (${normalizedEvent.tier}): "${text}"`);
932
+ this.emitToRenderer(IPC_CHANNELS.TRANSCRIPTION_FINAL, {
933
+ text,
934
+ confidence: normalizedEvent.confidence,
935
+ timestamp: normalizedEvent.timestamp,
936
+ tier: normalizedEvent.tier,
937
+ });
938
+ } else {
939
+ this.emitToRenderer(IPC_CHANNELS.TRANSCRIPTION_UPDATE, {
940
+ text,
941
+ confidence: normalizedEvent.confidence,
942
+ timestamp: normalizedEvent.timestamp,
943
+ isFinal: false,
944
+ tier: normalizedEvent.tier,
945
+ });
946
+ }
947
+ }
948
+
949
+
950
+
951
+ /**
952
+ * Handle service errors with categorized error handling
953
+ */
954
+ private handleServiceError(service: string, error: Error): void {
955
+ const context = {
956
+ component: 'SessionController',
957
+ operation: `${service}Error`,
958
+ data: { service, sessionId: this.session?.id },
959
+ };
960
+
961
+ // Log the error
962
+ errorHandler.log('error', `${service} error`, {
963
+ ...context,
964
+ error: error.message,
965
+ });
966
+
967
+ // Handle based on service type
968
+ switch (service) {
969
+ case 'audio':
970
+ errorHandler.handleAudioError(error, context);
971
+ break;
972
+ case 'transcription':
973
+ errorHandler.handleTranscriptionError(error, context);
974
+ break;
975
+ case 'capture':
976
+ errorHandler.handleCaptureError(error, context);
977
+ break;
978
+ default:
979
+ // Generic error handling
980
+ errorHandler.log('error', `Unknown service error: ${service}`, context);
981
+ }
982
+
983
+ // Notify event callbacks
984
+ this.events?.onError(error);
985
+
986
+ // Emit to renderer for UI feedback
987
+ this.emitToRenderer(IPC_CHANNELS.SESSION_ERROR, {
988
+ service,
989
+ message: error.message,
990
+ category: errorHandler.categorizeError(error),
991
+ });
992
+ }
993
+
994
+ // ===========================================================================
995
+ // Persistence
996
+ // ===========================================================================
997
+
998
+ /**
999
+ * Start auto-save timer
1000
+ */
1001
+ private startAutoSave(): void {
1002
+ this.stopAutoSave();
1003
+
1004
+ this.autoSaveTimer = setInterval(() => {
1005
+ if (this.session) {
1006
+ this.persistSession();
1007
+ console.log('[SessionController] Auto-saved session');
1008
+ }
1009
+ }, this.AUTO_SAVE_INTERVAL_MS);
1010
+ }
1011
+
1012
+ /**
1013
+ * Stop auto-save timer
1014
+ */
1015
+ private stopAutoSave(): void {
1016
+ if (this.autoSaveTimer) {
1017
+ clearInterval(this.autoSaveTimer);
1018
+ this.autoSaveTimer = null;
1019
+ }
1020
+ }
1021
+
1022
+ /**
1023
+ * Persist current session metadata to disk
1024
+ * Note: We don't persist full session with buffers, just metadata
1025
+ */
1026
+ private persistSession(): void {
1027
+ if (this.session) {
1028
+ const persisted: PersistedSession = {
1029
+ id: this.session.id,
1030
+ startTime: this.session.startTime,
1031
+ endTime: this.session.endTime,
1032
+ state: this.session.state,
1033
+ sourceId: this.session.sourceId,
1034
+ feedbackItemCount: this.session.feedbackItems.length,
1035
+ metadata: this.session.metadata,
1036
+ };
1037
+ store.set('currentSession', persisted);
1038
+ }
1039
+ }
1040
+
1041
+ // ===========================================================================
1042
+ // Watchdog Timer - Monitors state age and forces recovery
1043
+ // ===========================================================================
1044
+
1045
+ /**
1046
+ * Start the watchdog timer.
1047
+ * Monitors state age and triggers recovery if a state exceeds its timeout.
1048
+ * Also handles recording duration warnings and limits.
1049
+ */
1050
+ private startWatchdog(): void {
1051
+ this.stopWatchdog();
1052
+ this.stateEnteredAt = Date.now();
1053
+ this.recordingWarningShown = false;
1054
+
1055
+ this.watchdogTimer = setInterval(() => {
1056
+ this.watchdogCheck();
1057
+ }, this.WATCHDOG_CHECK_INTERVAL_MS);
1058
+
1059
+ console.log('[SessionController] Watchdog started');
1060
+ }
1061
+
1062
+ /**
1063
+ * Stop the watchdog timer.
1064
+ */
1065
+ private stopWatchdog(): void {
1066
+ if (this.watchdogTimer) {
1067
+ clearInterval(this.watchdogTimer);
1068
+ this.watchdogTimer = null;
1069
+ }
1070
+ }
1071
+
1072
+ /**
1073
+ * Watchdog check - runs every second.
1074
+ * Monitors state timeouts and recording duration.
1075
+ */
1076
+ private watchdogCheck(): void {
1077
+ const elapsed = Date.now() - this.stateEnteredAt;
1078
+ const timeout = STATE_TIMEOUTS[this.state];
1079
+
1080
+ // Check state timeout (recording uses active-duration checks below)
1081
+ if (this.state !== 'recording' && timeout !== null && elapsed > timeout) {
1082
+ console.error(
1083
+ `[SessionController] WATCHDOG: State '${this.state}' exceeded ${timeout}ms timeout (elapsed: ${elapsed}ms). Forcing recovery.`
1084
+ );
1085
+ this.forceRecovery();
1086
+ return;
1087
+ }
1088
+
1089
+ // Check recording-specific limits
1090
+ if (this.state === 'recording') {
1091
+ this.checkRecordingDuration();
1092
+ }
1093
+ }
1094
+
1095
+ /**
1096
+ * Check recording duration and emit warnings/force stop.
1097
+ */
1098
+ private checkRecordingDuration(): void {
1099
+ const elapsed = this.getActiveDurationMs();
1100
+
1101
+ // Warning at 25 minutes
1102
+ if (!this.recordingWarningShown && elapsed >= RECORDING_LIMITS.WARNING_DURATION_MS) {
1103
+ this.recordingWarningShown = true;
1104
+ const remainingMinutes = Math.ceil(
1105
+ (RECORDING_LIMITS.MAX_DURATION_MS - elapsed) / 60_000
1106
+ );
1107
+
1108
+ console.log(`[SessionController] Recording warning: ${remainingMinutes} minutes remaining`);
1109
+
1110
+ this.emitToRenderer(IPC_CHANNELS.SESSION_WARNING, {
1111
+ type: 'duration',
1112
+ message: `Recording will auto-stop in ${remainingMinutes} minutes`,
1113
+ remainingMs: RECORDING_LIMITS.MAX_DURATION_MS - elapsed,
1114
+ });
1115
+ }
1116
+
1117
+ // Force stop at 30 minutes
1118
+ if (elapsed >= RECORDING_LIMITS.MAX_DURATION_MS) {
1119
+ console.log('[SessionController] Recording max duration reached, auto-stopping');
1120
+ this.emitToRenderer(IPC_CHANNELS.SESSION_WARNING, {
1121
+ type: 'maxDuration',
1122
+ message: 'Maximum recording duration reached. Stopping automatically.',
1123
+ });
1124
+ this.stop(); // Will be wrapped with its own timeouts
1125
+ }
1126
+ }
1127
+
1128
+ /**
1129
+ * Force recovery from a stuck state.
1130
+ * Called by watchdog when a state exceeds its timeout.
1131
+ */
1132
+ private forceRecovery(): void {
1133
+ console.log(`[SessionController] Force recovery from state: ${this.state}`);
1134
+
1135
+ switch (this.state) {
1136
+ case 'starting':
1137
+ // Starting timed out - abort to idle
1138
+ this.handleTimeoutError('Service initialization timed out');
1139
+ this.cleanupServicesForced();
1140
+ this.transitionForced('idle');
1141
+ break;
1142
+
1143
+ case 'recording':
1144
+ // Recording hit 30 minute limit - force stop
1145
+ this.stop().catch((error) => {
1146
+ console.error('[SessionController] Force stop failed:', error);
1147
+ this.handleTimeoutError('Recording auto-stop failed');
1148
+ this.cleanupServicesForced();
1149
+ this.transitionForced('error');
1150
+ });
1151
+ break;
1152
+
1153
+ case 'stopping':
1154
+ // Stopping timed out - force to processing anyway
1155
+ console.warn('[SessionController] Stopping timeout, forcing to processing');
1156
+ this.cleanupServicesForced();
1157
+ this.transitionForced('processing');
1158
+ // Reset state entry time for processing timeout
1159
+ this.stateEnteredAt = Date.now();
1160
+ break;
1161
+
1162
+ case 'processing':
1163
+ // Processing timed out - complete with partial data
1164
+ console.warn('[SessionController] Processing timeout, completing with partial data');
1165
+ if (this.session) {
1166
+ this.session.endTime = this.session.endTime || Date.now();
1167
+ this.session.state = 'complete';
1168
+ this.addToRecentSessions(this.session);
1169
+ store.set('currentSession', null);
1170
+ }
1171
+ this.transitionForced('complete');
1172
+ this.stateEnteredAt = Date.now();
1173
+ break;
1174
+
1175
+ case 'complete':
1176
+ // Complete state timeout - reset to idle
1177
+ console.log('[SessionController] Complete timeout, resetting to idle');
1178
+ this.session = null;
1179
+ this.transitionForced('idle');
1180
+ break;
1181
+
1182
+ case 'error':
1183
+ // Error state timeout - reset to idle
1184
+ console.log('[SessionController] Error timeout, resetting to idle');
1185
+ this.session = null;
1186
+ this.transitionForced('idle');
1187
+ break;
1188
+
1189
+ case 'idle':
1190
+ // Should never happen (idle has no timeout)
1191
+ console.warn('[SessionController] Unexpected watchdog trigger in idle state');
1192
+ break;
1193
+ }
1194
+ }
1195
+
1196
+ /**
1197
+ * Force a state transition without validation.
1198
+ * ONLY used by watchdog recovery - bypasses normal transition checks.
1199
+ */
1200
+ private transitionForced(newState: SessionState): void {
1201
+ const oldState = this.state;
1202
+ this.state = newState;
1203
+ this.stateEnteredAt = Date.now();
1204
+
1205
+ if (this.session) {
1206
+ this.session.state = newState;
1207
+ }
1208
+
1209
+ console.log(`[SessionController] Forced transition: ${oldState} -> ${newState}`);
1210
+ this.emitStateChange();
1211
+ }
1212
+
1213
+ /**
1214
+ * Handle timeout errors - emit to renderer and log.
1215
+ */
1216
+ private handleTimeoutError(message: string): void {
1217
+ const error = new Error(message);
1218
+
1219
+ console.error(`[SessionController] Timeout error: ${message}`);
1220
+
1221
+ this.emitToRenderer(IPC_CHANNELS.SESSION_ERROR, {
1222
+ type: 'timeout',
1223
+ message,
1224
+ state: this.state,
1225
+ timestamp: Date.now(),
1226
+ });
1227
+
1228
+ this.events?.onError(error);
1229
+ }
1230
+
1231
+ /**
1232
+ * Force cleanup without waiting for services.
1233
+ * Used by watchdog when cleanup itself times out.
1234
+ */
1235
+ private cleanupServicesForced(): void {
1236
+ console.log('[SessionController] FORCED service cleanup');
1237
+ this.isPaused = false;
1238
+
1239
+ // Stop timers
1240
+ this.stopAutoSave();
1241
+ this.stopDurationTimer();
1242
+
1243
+ // Unsubscribe without waiting
1244
+ for (const cleanup of this.cleanupFunctions) {
1245
+ try {
1246
+ cleanup();
1247
+ } catch {
1248
+ // Ignore errors in forced cleanup
1249
+ }
1250
+ }
1251
+ this.cleanupFunctions = [];
1252
+
1253
+ // Try to stop services but don't wait
1254
+ try {
1255
+ void this.audioCaptureService.stop();
1256
+ } catch {
1257
+ // Ignore
1258
+ }
1259
+ // No-op: transcription recovery is post-session and does not run as a live service.
1260
+ }
1261
+
1262
+ /**
1263
+ * Add session to recent sessions list (full session)
1264
+ */
1265
+ private addToRecentSessions(session: Session): void {
1266
+ const persisted: PersistedSession = {
1267
+ id: session.id,
1268
+ startTime: session.startTime,
1269
+ endTime: session.endTime,
1270
+ state: session.state,
1271
+ sourceId: session.sourceId,
1272
+ feedbackItemCount: session.feedbackItems.length,
1273
+ metadata: session.metadata,
1274
+ };
1275
+ this.addToRecentSessionsPersisted(persisted);
1276
+ }
1277
+
1278
+ /**
1279
+ * Add persisted session to recent sessions list
1280
+ */
1281
+ private addToRecentSessionsPersisted(session: PersistedSession): void {
1282
+ const recent = store.get('recentSessions') || [];
1283
+
1284
+ // Add to front
1285
+ recent.unshift(session);
1286
+
1287
+ // Limit size
1288
+ if (recent.length > this.MAX_RECENT_SESSIONS) {
1289
+ recent.splice(this.MAX_RECENT_SESSIONS);
1290
+ }
1291
+
1292
+ store.set('recentSessions', recent);
1293
+ }
1294
+
1295
+ // ===========================================================================
1296
+ // Duration Timer
1297
+ // ===========================================================================
1298
+
1299
+ /**
1300
+ * Start duration timer (updates UI)
1301
+ */
1302
+ private startDurationTimer(): void {
1303
+ this.stopDurationTimer();
1304
+
1305
+ this.durationTimer = setInterval(() => {
1306
+ this.emitToRenderer(IPC_CHANNELS.SESSION_STATUS, this.getStatus());
1307
+ }, 1000);
1308
+ }
1309
+
1310
+ /**
1311
+ * Stop duration timer
1312
+ */
1313
+ private stopDurationTimer(): void {
1314
+ if (this.durationTimer) {
1315
+ clearInterval(this.durationTimer);
1316
+ this.durationTimer = null;
1317
+ }
1318
+ }
1319
+
1320
+ // ===========================================================================
1321
+ // Event Emission
1322
+ // ===========================================================================
1323
+
1324
+ /**
1325
+ * Emit state change to listeners
1326
+ */
1327
+ private emitStateChange(): void {
1328
+ this.events?.onStateChange(this.state, this.session);
1329
+ this.emitStatus();
1330
+ }
1331
+
1332
+ private emitStatus(): void {
1333
+ this.emitToRenderer(IPC_CHANNELS.SESSION_STATUS, this.getStatus());
1334
+ }
1335
+
1336
+ /**
1337
+ * Emit feedback item to listeners
1338
+ */
1339
+ private emitFeedbackItem(item: FeedbackItem): void {
1340
+ this.events?.onFeedbackItem(item);
1341
+ this.emitToRenderer(IPC_CHANNELS.SESSION_FEEDBACK_ITEM, {
1342
+ id: item.id,
1343
+ timestamp: item.timestamp,
1344
+ text: item.text,
1345
+ confidence: item.confidence,
1346
+ });
1347
+ }
1348
+
1349
+ /**
1350
+ * Send event to renderer via IPC
1351
+ */
1352
+ private emitToRenderer(channel: string, data: unknown): void {
1353
+ if (this.mainWindow && !this.mainWindow.isDestroyed()) {
1354
+ this.mainWindow.webContents.send(channel, data);
1355
+ }
1356
+ }
1357
+
1358
+ // ===========================================================================
1359
+ // Cleanup
1360
+ // ===========================================================================
1361
+
1362
+ /**
1363
+ * Clean up all services and timers (synchronous version for backwards compatibility)
1364
+ */
1365
+ private cleanupServices(): void {
1366
+ this.closeActivePauseWindow();
1367
+ this.isPaused = false;
1368
+ // Stop timers
1369
+ this.stopAutoSave();
1370
+ this.stopDurationTimer();
1371
+ // Unsubscribe from all events
1372
+ for (const cleanup of this.cleanupFunctions) {
1373
+ try {
1374
+ cleanup();
1375
+ } catch (error) {
1376
+ console.warn('[SessionController] Cleanup callback error:', error);
1377
+ }
1378
+ }
1379
+ this.cleanupFunctions = [];
1380
+
1381
+ // Stop services
1382
+ void this.audioCaptureService.stop();
1383
+ }
1384
+
1385
+ /**
1386
+ * Clean up all services and timers with timeout protection.
1387
+ * Never blocks for more than 2 seconds per service.
1388
+ */
1389
+ private async cleanupServicesAsync(): Promise<void> {
1390
+ console.log('[SessionController] Cleaning up services...');
1391
+ this.closeActivePauseWindow();
1392
+ this.isPaused = false;
1393
+
1394
+ // Stop timers first (fast, non-blocking)
1395
+ this.stopAutoSave();
1396
+ this.stopDurationTimer();
1397
+
1398
+ // Unsubscribe from all events (fast, non-blocking)
1399
+ for (const cleanup of this.cleanupFunctions) {
1400
+ try {
1401
+ cleanup();
1402
+ } catch (error) {
1403
+ console.warn('[SessionController] Cleanup callback error:', error);
1404
+ }
1405
+ }
1406
+ this.cleanupFunctions = [];
1407
+
1408
+ // Stop services with timeout protection (may block)
1409
+ const serviceTimeout = 2000; // 2 seconds per service
1410
+
1411
+ const [audioResult] = await Promise.allSettled([
1412
+ this.withTimeout(
1413
+ this.audioCaptureService.stop(),
1414
+ serviceTimeout,
1415
+ undefined,
1416
+ 'AudioCapture.stop()'
1417
+ ),
1418
+ ]);
1419
+
1420
+ // Log any failures
1421
+ if (audioResult.status === 'rejected') {
1422
+ console.warn('[SessionController] Audio cleanup failed:', audioResult.reason);
1423
+ }
1424
+ console.log('[SessionController] Service cleanup complete');
1425
+ }
1426
+
1427
+ /**
1428
+ * Full cleanup for app shutdown.
1429
+ * Mark clean exit for crash recovery.
1430
+ */
1431
+ destroy(): void {
1432
+ console.log('[SessionController] Destroying...');
1433
+
1434
+ // Save any active session
1435
+ if (this.session && (this.state === 'recording' || this.state === 'starting')) {
1436
+ this.session.state = 'complete';
1437
+ this.session.endTime = Date.now();
1438
+ this.addToRecentSessions(this.session);
1439
+ }
1440
+
1441
+ // Stop all timers
1442
+ this.stopWatchdog();
1443
+ this.cleanupServicesForced();
1444
+
1445
+ // Clear state
1446
+ this.session = null;
1447
+ this.resetSessionRuntimeState();
1448
+ this.postProcessResult = null;
1449
+ this.currentProcessingProgress = null;
1450
+ this.events = null;
1451
+ this.mainWindow = null;
1452
+
1453
+ console.log('[SessionController] Destroy complete');
1454
+ }
1455
+ }
1456
+
1457
+ // =============================================================================
1458
+ // Singleton Export
1459
+ // =============================================================================
1460
+
1461
+ export const sessionController = new SessionController();
1462
+ export default SessionController;
1463
+
1464
+ // Re-export types from shared/types for downstream consumers
1465
+ export type { SessionState, SessionMetadata } from '../shared/types';