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,730 @@
1
+ /**
2
+ * SettingsManager - Secure Settings Storage for markupr
3
+ *
4
+ * Handles:
5
+ * - Persistent settings storage with electron-store (schema validated)
6
+ * - Secure API key storage with keytar (macOS Keychain, Windows Credential Manager)
7
+ * - Encrypted fallback key storage via safeStorage when keytar is unavailable
8
+ * - Settings migration between versions
9
+ * - Change event emission for reactive updates
10
+ * - IPC handlers for renderer access
11
+ *
12
+ * Security:
13
+ * - keytar uses OS-level secure storage (Keychain, Credential Manager)
14
+ * - fallback secrets are encrypted with safeStorage before disk persistence
15
+ * - plaintext fallback is only used as a last resort when secure storage is unavailable
16
+ * - Settings are validated against schema before saving
17
+ */
18
+
19
+ import Store from 'electron-store';
20
+ import * as keytar from 'keytar';
21
+ import { app, ipcMain, safeStorage } from 'electron';
22
+ import { join } from 'path';
23
+ import { IPC_CHANNELS, type AppSettings, type HotkeyConfig } from '../../shared/types';
24
+
25
+ // AppSettings is imported from '../../shared/types' (single source of truth)
26
+
27
+ /**
28
+ * Settings change callback type
29
+ */
30
+ type SettingsChangeCallback = (key: string, newValue: unknown, oldValue: unknown) => void;
31
+
32
+ /**
33
+ * SettingsManager interface
34
+ */
35
+ export interface ISettingsManager {
36
+ // Core
37
+ get<K extends keyof AppSettings>(key: K): AppSettings[K];
38
+ set<K extends keyof AppSettings>(key: K, value: AppSettings[K]): void;
39
+ getAll(): AppSettings;
40
+ reset(): void;
41
+
42
+ // Secure storage (API keys)
43
+ getApiKey(service: string): Promise<string | null>;
44
+ setApiKey(service: string, key: string): Promise<void>;
45
+ deleteApiKey(service: string): Promise<void>;
46
+ hasApiKey(service: string): Promise<boolean>;
47
+
48
+ // Events
49
+ onChange(callback: SettingsChangeCallback): () => void;
50
+
51
+ // Migration
52
+ migrate(): void;
53
+
54
+ // IPC
55
+ registerIpcHandlers(): void;
56
+ }
57
+
58
+ // ============================================================================
59
+ // Constants
60
+ // ============================================================================
61
+
62
+ const KEYTAR_SERVICE = 'com.markupr.app';
63
+ const LEGACY_KEYTAR_SERVICES = ['com.feedbackflow.app', 'feedbackflow'] as const;
64
+ const FALLBACK_SECRET_STORE_NAME = 'secure-keys';
65
+ const LEGACY_INSECURE_SECRET_STORE_KEY = '__plaintext_fallback__';
66
+ const INSECURE_SECRET_PREFIX = 'plaintext:';
67
+ const SETTINGS_VERSION = 2;
68
+
69
+ /**
70
+ * Default hotkey configuration
71
+ */
72
+ const DEFAULT_HOTKEY_CONFIG: HotkeyConfig = {
73
+ toggleRecording: 'CommandOrControl+Shift+F',
74
+ manualScreenshot: 'CommandOrControl+Shift+S',
75
+ pauseResume: 'CommandOrControl+Shift+P',
76
+ };
77
+
78
+ /**
79
+ * Default settings values
80
+ */
81
+ const DEFAULT_SETTINGS: AppSettings = {
82
+ // General
83
+ outputDirectory: '', // Set dynamically in constructor
84
+ launchAtLogin: false,
85
+ checkForUpdates: true,
86
+
87
+ // Recording
88
+ defaultCountdown: 0,
89
+ showTranscriptionPreview: true,
90
+ showAudioWaveform: true,
91
+
92
+ // Capture
93
+ pauseThreshold: 1500,
94
+ minTimeBetweenCaptures: 500,
95
+ imageFormat: 'png',
96
+ imageQuality: 85,
97
+ maxImageWidth: 1920,
98
+
99
+ // Transcription
100
+ transcriptionService: 'openai',
101
+ language: 'en',
102
+ enableKeywordTriggers: false,
103
+
104
+ // Hotkeys
105
+ hotkeys: { ...DEFAULT_HOTKEY_CONFIG },
106
+
107
+ // Appearance
108
+ theme: 'system',
109
+ accentColor: '#3B82F6', // Blue-500
110
+
111
+ // Audio
112
+ audioDeviceId: null,
113
+
114
+ // Advanced
115
+ debugMode: false,
116
+ keepAudioBackups: false,
117
+
118
+ // Onboarding
119
+ hasCompletedOnboarding: false,
120
+ };
121
+
122
+ /**
123
+ * Schema for electron-store validation
124
+ */
125
+ const SETTINGS_SCHEMA = {
126
+ outputDirectory: { type: 'string' },
127
+ launchAtLogin: { type: 'boolean' },
128
+ checkForUpdates: { type: 'boolean' },
129
+ defaultCountdown: { type: 'number', enum: [0, 3, 5] },
130
+ showTranscriptionPreview: { type: 'boolean' },
131
+ showAudioWaveform: { type: 'boolean' },
132
+ pauseThreshold: { type: 'number', minimum: 500, maximum: 3000 },
133
+ minTimeBetweenCaptures: { type: 'number', minimum: 300, maximum: 2000 },
134
+ imageFormat: { type: 'string', enum: ['png', 'jpeg'] },
135
+ imageQuality: { type: 'number', minimum: 1, maximum: 100 },
136
+ maxImageWidth: { type: 'number', minimum: 800, maximum: 2400 },
137
+ transcriptionService: { type: 'string', enum: ['openai'] },
138
+ language: { type: 'string' },
139
+ enableKeywordTriggers: { type: 'boolean' },
140
+ hotkeys: {
141
+ type: 'object',
142
+ properties: {
143
+ toggleRecording: { type: 'string' },
144
+ manualScreenshot: { type: 'string' },
145
+ pauseResume: { type: 'string' },
146
+ },
147
+ },
148
+ theme: { type: 'string', enum: ['dark', 'light', 'system'] },
149
+ accentColor: { type: 'string' },
150
+ audioDeviceId: { type: ['string', 'null'] },
151
+ debugMode: { type: 'boolean' },
152
+ keepAudioBackups: { type: 'boolean' },
153
+ hasCompletedOnboarding: { type: 'boolean' },
154
+ } as const;
155
+
156
+ // ============================================================================
157
+ // Implementation
158
+ // ============================================================================
159
+
160
+ export class SettingsManager implements ISettingsManager {
161
+ private store: Store<AppSettings>;
162
+ private secureStore: Store<Record<string, string>>;
163
+ private changeCallbacks: Set<SettingsChangeCallback> = new Set();
164
+ private ipcRegistered = false;
165
+
166
+ constructor() {
167
+ // Initialize electron-store with schema
168
+ // We use type assertion here because electron-store's Schema type is overly strict
169
+ // and doesn't match JSON Schema 7 format we're using
170
+ this.store = new Store<AppSettings>({
171
+ name: 'settings',
172
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
173
+ schema: SETTINGS_SCHEMA as any,
174
+ defaults: this.getDefaultsWithPaths(),
175
+ clearInvalidConfig: false, // Don't clear on schema violation, migrate instead
176
+ });
177
+ this.secureStore = new Store<Record<string, string>>({
178
+ name: FALLBACK_SECRET_STORE_NAME,
179
+ clearInvalidConfig: false,
180
+ });
181
+
182
+ // Run migrations
183
+ this.migrate();
184
+ this.store.set('hotkeys', {
185
+ ...DEFAULT_HOTKEY_CONFIG,
186
+ ...(this.store.get('hotkeys') || {}),
187
+ });
188
+ this.normalizeTranscriptionService();
189
+
190
+ console.log('[SettingsManager] Initialized with settings version:', SETTINGS_VERSION);
191
+ }
192
+
193
+ /**
194
+ * Get defaults with dynamic paths resolved
195
+ */
196
+ private getDefaultsWithPaths(): AppSettings {
197
+ const documentsPath = app.isReady()
198
+ ? app.getPath('documents')
199
+ : join(process.env.HOME || process.env.USERPROFILE || '', 'Documents');
200
+
201
+ return {
202
+ ...DEFAULT_SETTINGS,
203
+ outputDirectory: join(documentsPath, 'markupr'),
204
+ };
205
+ }
206
+
207
+ // --------------------------------------------------------------------------
208
+ // Core Methods
209
+ // --------------------------------------------------------------------------
210
+
211
+ /**
212
+ * Get a single setting value
213
+ */
214
+ get<K extends keyof AppSettings>(key: K): AppSettings[K] {
215
+ return this.store.get(key);
216
+ }
217
+
218
+ /**
219
+ * Set a single setting value
220
+ */
221
+ set<K extends keyof AppSettings>(key: K, value: AppSettings[K]): void {
222
+ const oldValue = this.store.get(key);
223
+
224
+ // Validate before setting
225
+ if (!this.validateSetting(key, value)) {
226
+ console.warn(`[SettingsManager] Invalid value for ${key}:`, value);
227
+ return;
228
+ }
229
+
230
+ this.store.set(key, value);
231
+ this.emitChange(key, value, oldValue);
232
+
233
+ console.log(`[SettingsManager] Set ${key}:`, value);
234
+ }
235
+
236
+ /**
237
+ * Get all settings
238
+ */
239
+ getAll(): AppSettings {
240
+ return this.store.store;
241
+ }
242
+
243
+ /**
244
+ * Reset all settings to defaults
245
+ */
246
+ reset(): void {
247
+ const oldSettings = this.getAll();
248
+ const defaults = this.getDefaultsWithPaths();
249
+
250
+ this.store.clear();
251
+ this.store.set(defaults);
252
+
253
+ // Emit changes for all settings
254
+ for (const key of Object.keys(defaults) as Array<keyof AppSettings>) {
255
+ if (oldSettings[key] !== defaults[key]) {
256
+ this.emitChange(key, defaults[key], oldSettings[key]);
257
+ }
258
+ }
259
+
260
+ console.log('[SettingsManager] Reset to defaults');
261
+ }
262
+
263
+ /**
264
+ * Update multiple settings at once (legacy compatibility method)
265
+ * Note: For new code, prefer using set() for individual settings
266
+ */
267
+ update(updates: Partial<AppSettings>): AppSettings {
268
+ for (const [key, value] of Object.entries(updates)) {
269
+ if (value !== undefined) {
270
+ this.set(key as keyof AppSettings, value as AppSettings[keyof AppSettings]);
271
+ }
272
+ }
273
+ return this.getAll();
274
+ }
275
+
276
+ // --------------------------------------------------------------------------
277
+ // Validation
278
+ // --------------------------------------------------------------------------
279
+
280
+ /**
281
+ * Validate a single setting value
282
+ */
283
+ private validateSetting<K extends keyof AppSettings>(key: K, value: AppSettings[K]): boolean {
284
+ switch (key) {
285
+ case 'pauseThreshold':
286
+ return typeof value === 'number' && value >= 500 && value <= 3000;
287
+
288
+ case 'minTimeBetweenCaptures':
289
+ return typeof value === 'number' && value >= 300 && value <= 2000;
290
+
291
+ case 'imageQuality':
292
+ return typeof value === 'number' && value >= 1 && value <= 100;
293
+
294
+ case 'maxImageWidth':
295
+ return typeof value === 'number' && value >= 800 && value <= 2400;
296
+
297
+ case 'defaultCountdown':
298
+ return value === 0 || value === 3 || value === 5;
299
+
300
+ case 'imageFormat':
301
+ return value === 'png' || value === 'jpeg';
302
+
303
+ case 'theme':
304
+ return value === 'dark' || value === 'light' || value === 'system';
305
+
306
+ case 'transcriptionService':
307
+ return (value as unknown as string) === 'openai';
308
+
309
+ case 'accentColor':
310
+ return typeof value === 'string' && /^#[0-9A-Fa-f]{6}$/.test(value as string);
311
+
312
+ default:
313
+ return true;
314
+ }
315
+ }
316
+
317
+ // --------------------------------------------------------------------------
318
+ // Secure Storage (API Keys)
319
+ // --------------------------------------------------------------------------
320
+
321
+ private canUseEncryptedFallback(): boolean {
322
+ try {
323
+ return safeStorage.isEncryptionAvailable();
324
+ } catch {
325
+ return false;
326
+ }
327
+ }
328
+
329
+ private getFallbackApiKey(service: string): string | null {
330
+ try {
331
+ const encrypted = this.secureStore.get(service);
332
+ if (!encrypted) {
333
+ return null;
334
+ }
335
+
336
+ if (!this.canUseEncryptedFallback()) {
337
+ console.warn(
338
+ `[SettingsManager] Encrypted fallback exists for ${service}, but safeStorage is unavailable`
339
+ );
340
+ return null;
341
+ }
342
+
343
+ return safeStorage.decryptString(Buffer.from(encrypted, 'base64'));
344
+ } catch (error) {
345
+ console.warn(`[SettingsManager] Failed to read fallback API key for ${service}:`, error);
346
+ return null;
347
+ }
348
+ }
349
+
350
+ private setFallbackApiKey(service: string, key: string): void {
351
+ if (!this.canUseEncryptedFallback()) {
352
+ throw new Error('safeStorage encryption is unavailable');
353
+ }
354
+
355
+ const encrypted = safeStorage.encryptString(key).toString('base64');
356
+ this.secureStore.set(service, encrypted);
357
+ }
358
+
359
+ private clearFallbackApiKey(service: string): void {
360
+ try {
361
+ this.secureStore.delete(service);
362
+ } catch (error) {
363
+ console.warn(`[SettingsManager] Failed to clear fallback API key for ${service}:`, error);
364
+ }
365
+ }
366
+
367
+ private getInsecureStoreKey(service: string): string {
368
+ return `${INSECURE_SECRET_PREFIX}${service}`;
369
+ }
370
+
371
+ private getInsecureApiKey(service: string): string | null {
372
+ const storeKey = this.getInsecureStoreKey(service);
373
+ const directValue = this.secureStore.get(storeKey);
374
+ if (typeof directValue === 'string' && directValue.length > 0) {
375
+ return directValue;
376
+ }
377
+
378
+ // Legacy fallback: previous builds stored a map under settings.json.
379
+ try {
380
+ const insecureMap = this.store.get(
381
+ LEGACY_INSECURE_SECRET_STORE_KEY as keyof AppSettings
382
+ ) as unknown as Record<string, string> | undefined;
383
+ const value = insecureMap?.[service];
384
+ return typeof value === 'string' && value.length > 0 ? value : null;
385
+ } catch (error) {
386
+ console.warn(`[SettingsManager] Failed to read plaintext fallback API key for ${service}:`, error);
387
+ return null;
388
+ }
389
+ }
390
+
391
+ private setInsecureApiKey(service: string, key: string): void {
392
+ const storeKey = this.getInsecureStoreKey(service);
393
+ this.secureStore.set(storeKey, key);
394
+
395
+ // Best-effort cleanup of legacy fallback map entry.
396
+ const legacyMap = (this.store.get(
397
+ LEGACY_INSECURE_SECRET_STORE_KEY as keyof AppSettings
398
+ ) as unknown as Record<string, string> | undefined) || {};
399
+ if (legacyMap[service]) {
400
+ delete legacyMap[service];
401
+ this.store.set(
402
+ LEGACY_INSECURE_SECRET_STORE_KEY as keyof AppSettings,
403
+ legacyMap as unknown as AppSettings[keyof AppSettings]
404
+ );
405
+ }
406
+ }
407
+
408
+ private clearInsecureApiKey(service: string): void {
409
+ try {
410
+ this.secureStore.delete(this.getInsecureStoreKey(service));
411
+ } catch (error) {
412
+ console.warn(`[SettingsManager] Failed to clear plaintext fallback API key for ${service}:`, error);
413
+ }
414
+
415
+ const legacyMap = (this.store.get(
416
+ LEGACY_INSECURE_SECRET_STORE_KEY as keyof AppSettings
417
+ ) as unknown as Record<string, string> | undefined) || {};
418
+ if (!legacyMap[service]) {
419
+ return;
420
+ }
421
+ delete legacyMap[service];
422
+ this.store.set(
423
+ LEGACY_INSECURE_SECRET_STORE_KEY as keyof AppSettings,
424
+ legacyMap as unknown as AppSettings[keyof AppSettings]
425
+ );
426
+ }
427
+
428
+ /**
429
+ * Get an API key from secure storage
430
+ */
431
+ async getApiKey(service: string): Promise<string | null> {
432
+ try {
433
+ const key = await keytar.getPassword(KEYTAR_SERVICE, service);
434
+ if (key) {
435
+ return key;
436
+ }
437
+
438
+ // Migration path: older builds stored keys under a different keychain service name.
439
+ for (const legacyService of LEGACY_KEYTAR_SERVICES) {
440
+ const legacyKey = await keytar.getPassword(legacyService, service);
441
+ if (!legacyKey) {
442
+ continue;
443
+ }
444
+
445
+ try {
446
+ await keytar.setPassword(KEYTAR_SERVICE, service, legacyKey);
447
+ console.log(
448
+ `[SettingsManager] Migrated API key for ${service} from "${legacyService}" to "${KEYTAR_SERVICE}"`
449
+ );
450
+ } catch (migrationError) {
451
+ console.warn(
452
+ `[SettingsManager] Failed to migrate API key for ${service} from "${legacyService}":`,
453
+ migrationError
454
+ );
455
+ }
456
+
457
+ return legacyKey;
458
+ }
459
+
460
+ return this.getFallbackApiKey(service) || this.getInsecureApiKey(service);
461
+ } catch (error) {
462
+ console.error(`[SettingsManager] Failed to get API key for ${service}:`, error);
463
+ return this.getFallbackApiKey(service) || this.getInsecureApiKey(service);
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Store an API key in secure storage
469
+ */
470
+ async setApiKey(service: string, key: string): Promise<void> {
471
+ try {
472
+ await keytar.setPassword(KEYTAR_SERVICE, service, key);
473
+ this.clearFallbackApiKey(service);
474
+ this.clearInsecureApiKey(service);
475
+ console.log(`[SettingsManager] Stored API key for ${service}`);
476
+ } catch (error) {
477
+ console.warn(
478
+ `[SettingsManager] Keytar store failed for ${service}; attempting encrypted fallback:`,
479
+ error
480
+ );
481
+
482
+ try {
483
+ this.setFallbackApiKey(service, key);
484
+ this.clearInsecureApiKey(service);
485
+ console.log(`[SettingsManager] Stored API key for ${service} via encrypted fallback`);
486
+ } catch (fallbackError) {
487
+ console.warn(
488
+ `[SettingsManager] Encrypted fallback failed for ${service}; storing plaintext fallback:`,
489
+ fallbackError
490
+ );
491
+ try {
492
+ this.setInsecureApiKey(service, key);
493
+ console.log(`[SettingsManager] Stored API key for ${service} via plaintext fallback`);
494
+ } catch (insecureError) {
495
+ throw new Error(
496
+ `Unable to store API key for ${service}: ${
497
+ insecureError instanceof Error ? insecureError.message : String(insecureError)
498
+ }`
499
+ );
500
+ }
501
+ }
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Delete an API key from secure storage
507
+ */
508
+ async deleteApiKey(service: string): Promise<void> {
509
+ let keytarError: unknown = null;
510
+ try {
511
+ await keytar.deletePassword(KEYTAR_SERVICE, service);
512
+ } catch (error) {
513
+ keytarError = error;
514
+ console.warn(`[SettingsManager] Failed to delete keytar API key for ${service}:`, error);
515
+ }
516
+
517
+ this.clearFallbackApiKey(service);
518
+ this.clearInsecureApiKey(service);
519
+
520
+ if (keytarError && !this.canUseEncryptedFallback()) {
521
+ return;
522
+ }
523
+
524
+ console.log(`[SettingsManager] Deleted API key for ${service}`);
525
+ }
526
+
527
+ /**
528
+ * Check if an API key exists in secure storage
529
+ */
530
+ async hasApiKey(service: string): Promise<boolean> {
531
+ const key = await this.getApiKey(service);
532
+ return key !== null && key.length > 0;
533
+ }
534
+
535
+ // --------------------------------------------------------------------------
536
+ // Change Events
537
+ // --------------------------------------------------------------------------
538
+
539
+ /**
540
+ * Subscribe to settings changes
541
+ * @returns Unsubscribe function
542
+ */
543
+ onChange(callback: SettingsChangeCallback): () => void {
544
+ this.changeCallbacks.add(callback);
545
+ return () => {
546
+ this.changeCallbacks.delete(callback);
547
+ };
548
+ }
549
+
550
+ /**
551
+ * Emit a change event to all subscribers
552
+ */
553
+ private emitChange(key: string, newValue: unknown, oldValue: unknown): void {
554
+ for (const callback of this.changeCallbacks) {
555
+ try {
556
+ callback(key, newValue, oldValue);
557
+ } catch (error) {
558
+ console.error('[SettingsManager] Error in change callback:', error);
559
+ }
560
+ }
561
+ }
562
+
563
+ // --------------------------------------------------------------------------
564
+ // Migration
565
+ // --------------------------------------------------------------------------
566
+
567
+ /**
568
+ * Run settings migrations
569
+ */
570
+ migrate(): void {
571
+ const currentVersion = this.store.get('_version' as keyof AppSettings) as number | undefined;
572
+
573
+ if (currentVersion === SETTINGS_VERSION) {
574
+ return;
575
+ }
576
+
577
+ console.log(`[SettingsManager] Migrating from version ${currentVersion || 1} to ${SETTINGS_VERSION}`);
578
+
579
+ // Migration from v1 (legacy settings)
580
+ if (!currentVersion || currentVersion < 2) {
581
+ this.migrateV1ToV2();
582
+ }
583
+
584
+ // Set current version
585
+ this.store.set('_version' as keyof AppSettings, SETTINGS_VERSION as unknown as AppSettings[keyof AppSettings]);
586
+ }
587
+
588
+ /**
589
+ * Normalize deprecated transcription service values to the current default.
590
+ */
591
+ private normalizeTranscriptionService(): void {
592
+ const current = this.store.get('transcriptionService') as unknown;
593
+ if (current === 'deepgram') {
594
+ this.store.set('transcriptionService', 'openai');
595
+ console.log('[SettingsManager] Normalized legacy transcriptionService "deepgram" -> "openai"');
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Migrate from v1 (legacy JSON settings) to v2 (electron-store with new schema)
601
+ */
602
+ private migrateV1ToV2(): void {
603
+ console.log('[SettingsManager] Running v1 -> v2 migration');
604
+
605
+ // Map old settings to new settings
606
+ const legacyMappings: Record<string, keyof AppSettings> = {
607
+ screenshotQuality: 'imageQuality',
608
+ pauseThresholdMs: 'pauseThreshold',
609
+ };
610
+
611
+ for (const [oldKey, newKey] of Object.entries(legacyMappings)) {
612
+ const oldValue = this.store.get(oldKey as keyof AppSettings);
613
+ if (oldValue !== undefined) {
614
+ this.store.set(newKey, oldValue);
615
+ this.store.delete(oldKey as keyof AppSettings);
616
+ console.log(`[SettingsManager] Migrated ${oldKey} -> ${newKey}`);
617
+ }
618
+ }
619
+
620
+ // Remove deprecated settings
621
+ const deprecatedKeys = ['autoClipboard', 'outputFormat', 'deepgramApiKey'];
622
+ for (const key of deprecatedKeys) {
623
+ if (this.store.has(key as keyof AppSettings)) {
624
+ this.store.delete(key as keyof AppSettings);
625
+ console.log(`[SettingsManager] Removed deprecated setting: ${key}`);
626
+ }
627
+ }
628
+
629
+ // Ensure all new settings have defaults
630
+ const defaults = this.getDefaultsWithPaths();
631
+ for (const [key, value] of Object.entries(defaults)) {
632
+ if (!this.store.has(key as keyof AppSettings)) {
633
+ this.store.set(key as keyof AppSettings, value as AppSettings[keyof AppSettings]);
634
+ }
635
+ }
636
+ }
637
+
638
+ // --------------------------------------------------------------------------
639
+ // IPC Handlers
640
+ // --------------------------------------------------------------------------
641
+
642
+ /**
643
+ * Register IPC handlers for renderer communication
644
+ */
645
+ registerIpcHandlers(): void {
646
+ if (this.ipcRegistered) {
647
+ console.warn('[SettingsManager] IPC handlers already registered');
648
+ return;
649
+ }
650
+
651
+ // Get single setting
652
+ ipcMain.handle(IPC_CHANNELS.SETTINGS_GET, (_, key: keyof AppSettings) => {
653
+ return this.get(key);
654
+ });
655
+
656
+ // Get all settings
657
+ ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_ALL, () => {
658
+ return this.getAll();
659
+ });
660
+
661
+ // Set single setting
662
+ ipcMain.handle(IPC_CHANNELS.SETTINGS_SET, (_, key: keyof AppSettings, value: AppSettings[keyof AppSettings]) => {
663
+ this.set(key, value);
664
+ return this.get(key);
665
+ });
666
+
667
+ // Get API key (secure)
668
+ ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_API_KEY, async (_, service: string) => {
669
+ return this.getApiKey(service);
670
+ });
671
+
672
+ // Set API key (secure)
673
+ ipcMain.handle(IPC_CHANNELS.SETTINGS_SET_API_KEY, async (_, service: string, key: string) => {
674
+ await this.setApiKey(service, key);
675
+ return true;
676
+ });
677
+
678
+ // Delete API key (secure)
679
+ ipcMain.handle(IPC_CHANNELS.SETTINGS_DELETE_API_KEY, async (_, service: string) => {
680
+ await this.deleteApiKey(service);
681
+ return true;
682
+ });
683
+
684
+ // Check if API key exists
685
+ ipcMain.handle(IPC_CHANNELS.SETTINGS_HAS_API_KEY, async (_, service: string) => {
686
+ return this.hasApiKey(service);
687
+ });
688
+
689
+ this.ipcRegistered = true;
690
+ console.log('[SettingsManager] IPC handlers registered');
691
+ }
692
+
693
+ /**
694
+ * Get the storage path for debugging
695
+ */
696
+ getStorePath(): string {
697
+ return this.store.path;
698
+ }
699
+ }
700
+
701
+ // ============================================================================
702
+ // Singleton Export
703
+ // ============================================================================
704
+
705
+ let instance: SettingsManager | null = null;
706
+
707
+ /**
708
+ * Get or create the SettingsManager singleton
709
+ */
710
+ export function getSettingsManager(): SettingsManager {
711
+ if (!instance) {
712
+ instance = new SettingsManager();
713
+ }
714
+ return instance;
715
+ }
716
+
717
+ /**
718
+ * Create a new SettingsManager instance (for testing)
719
+ */
720
+ export function createSettingsManager(): SettingsManager {
721
+ return new SettingsManager();
722
+ }
723
+
724
+ // Singleton instance
725
+ export const settingsManager = getSettingsManager();
726
+
727
+ export { DEFAULT_SETTINGS, SETTINGS_VERSION };
728
+ // Re-export AppSettings from shared/types for downstream consumers
729
+ export type { AppSettings } from '../../shared/types';
730
+ export default settingsManager;