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,844 @@
1
+ /**
2
+ * ModelDownloadDialog.tsx - Whisper Model Download Prompt
3
+ *
4
+ * Shown on first launch when no Whisper model is downloaded.
5
+ * Allows users to download the recommended model for offline transcription.
6
+ */
7
+
8
+ import React, { useState, useEffect, useCallback } from 'react';
9
+ import { useTheme } from '../hooks/useTheme';
10
+
11
+ // ============================================================================
12
+ // Types
13
+ // ============================================================================
14
+
15
+ interface ModelDownloadDialogProps {
16
+ onComplete: () => void;
17
+ onSkip: () => void;
18
+ }
19
+
20
+ interface ModelInfo {
21
+ name: string;
22
+ filename: string;
23
+ sizeMB: number;
24
+ ramRequired: string;
25
+ quality: string;
26
+ isDownloaded: boolean;
27
+ }
28
+
29
+ interface DownloadProgress {
30
+ model: string;
31
+ downloadedBytes: number;
32
+ totalBytes: number;
33
+ percent: number;
34
+ speedBps: number;
35
+ estimatedSecondsRemaining: number;
36
+ }
37
+
38
+ type DialogState = 'prompt' | 'downloading' | 'complete' | 'error';
39
+
40
+ // ============================================================================
41
+ // Helper Functions
42
+ // ============================================================================
43
+
44
+ function formatBytes(bytes: number): string {
45
+ if (bytes === 0) return '0 B';
46
+ const k = 1024;
47
+ const sizes = ['B', 'KB', 'MB', 'GB'];
48
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
49
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
50
+ }
51
+
52
+ function formatSpeed(bytesPerSecond: number): string {
53
+ return formatBytes(bytesPerSecond) + '/s';
54
+ }
55
+
56
+ function formatTime(seconds: number): string {
57
+ if (seconds < 60) return `${Math.round(seconds)}s`;
58
+ const minutes = Math.floor(seconds / 60);
59
+ const secs = Math.round(seconds % 60);
60
+ return `${minutes}m ${secs}s`;
61
+ }
62
+
63
+ // ============================================================================
64
+ // useModelDownload Hook
65
+ // ============================================================================
66
+
67
+ interface UseModelDownloadResult {
68
+ isDownloading: boolean;
69
+ progress: DownloadProgress | null;
70
+ error: string | null;
71
+ downloadModel: (model: string) => Promise<void>;
72
+ cancelDownload: (model: string) => void;
73
+ }
74
+
75
+ function useModelDownload(): UseModelDownloadResult {
76
+ const [isDownloading, setIsDownloading] = useState(false);
77
+ const [progress, setProgress] = useState<DownloadProgress | null>(null);
78
+ const [error, setError] = useState<string | null>(null);
79
+
80
+ useEffect(() => {
81
+ // Subscribe to download events
82
+ const unsubProgress = window.markupr.whisper.onDownloadProgress((p) => {
83
+ setProgress(p);
84
+ });
85
+
86
+ const unsubComplete = window.markupr.whisper.onDownloadComplete(() => {
87
+ setIsDownloading(false);
88
+ setProgress(null);
89
+ });
90
+
91
+ const unsubError = window.markupr.whisper.onDownloadError(({ error: err }) => {
92
+ setIsDownloading(false);
93
+ setError(err);
94
+ });
95
+
96
+ return () => {
97
+ unsubProgress();
98
+ unsubComplete();
99
+ unsubError();
100
+ };
101
+ }, []);
102
+
103
+ const downloadModel = useCallback(async (model: string) => {
104
+ setIsDownloading(true);
105
+ setError(null);
106
+ setProgress(null);
107
+
108
+ const result = await window.markupr.whisper.downloadModel(model);
109
+ if (!result.success && result.error) {
110
+ setError(result.error);
111
+ setIsDownloading(false);
112
+ }
113
+ }, []);
114
+
115
+ const cancelDownload = useCallback((model: string) => {
116
+ window.markupr.whisper.cancelDownload(model);
117
+ setIsDownloading(false);
118
+ setProgress(null);
119
+ }, []);
120
+
121
+ return {
122
+ isDownloading,
123
+ progress,
124
+ error,
125
+ downloadModel,
126
+ cancelDownload,
127
+ };
128
+ }
129
+
130
+ // ============================================================================
131
+ // Main Component
132
+ // ============================================================================
133
+
134
+ export const ModelDownloadDialog: React.FC<ModelDownloadDialogProps> = ({
135
+ onComplete,
136
+ onSkip,
137
+ }) => {
138
+ const [state, setState] = useState<DialogState>('prompt');
139
+ const [selectedModel, setSelectedModel] = useState<string>('tiny');
140
+ const [models, setModels] = useState<ModelInfo[]>([]);
141
+ const [showAdvanced, setShowAdvanced] = useState(false);
142
+ const { colors } = useTheme();
143
+
144
+ const { isDownloading, progress, error, downloadModel, cancelDownload } = useModelDownload();
145
+
146
+ // Load available models on mount
147
+ useEffect(() => {
148
+ const loadModels = async () => {
149
+ const availableModels = await window.markupr.whisper.getAvailableModels();
150
+ setModels(availableModels);
151
+ };
152
+ loadModels();
153
+ }, []);
154
+
155
+ // Update state based on download status
156
+ useEffect(() => {
157
+ if (isDownloading) {
158
+ setState('downloading');
159
+ } else if (error) {
160
+ setState('error');
161
+ }
162
+ }, [isDownloading, error]);
163
+
164
+ // Listen for download complete to transition state
165
+ useEffect(() => {
166
+ const unsubComplete = window.markupr.whisper.onDownloadComplete(() => {
167
+ setState('complete');
168
+ });
169
+ return unsubComplete;
170
+ }, []);
171
+
172
+ const handleDownload = useCallback(async () => {
173
+ await downloadModel(selectedModel);
174
+ }, [selectedModel, downloadModel]);
175
+
176
+ const handleCancel = useCallback(() => {
177
+ cancelDownload(selectedModel);
178
+ setState('prompt');
179
+ }, [selectedModel, cancelDownload]);
180
+
181
+ const selectedModelInfo = models.find((m) => m.name === selectedModel);
182
+
183
+ // Render different states
184
+ const renderContent = () => {
185
+ switch (state) {
186
+ case 'prompt':
187
+ return (
188
+ <>
189
+ {/* Illustration */}
190
+ <div style={styles.illustrationContainer}>
191
+ <div style={styles.iconCircle}>
192
+ <svg width="48" height="48" viewBox="0 0 48 48" fill="none">
193
+ <path
194
+ d="M24 8c-3.3 0-6 2.7-6 6v9c0 3.3 2.7 6 6 6s6-2.7 6-6v-9c0-3.3-2.7-6-6-6z"
195
+ stroke={colors.accent.default}
196
+ strokeWidth="2.5"
197
+ fill="none"
198
+ />
199
+ <path
200
+ d="M36 20v3c0 6.6-5.4 12-12 12s-12-5.4-12-12v-3"
201
+ stroke={colors.accent.default}
202
+ strokeWidth="2.5"
203
+ strokeLinecap="round"
204
+ />
205
+ <path
206
+ d="M24 35v5M18 40h12"
207
+ stroke={colors.accent.default}
208
+ strokeWidth="2.5"
209
+ strokeLinecap="round"
210
+ />
211
+ {/* Download arrow */}
212
+ <path
213
+ d="M38 28v6h-6M38 34l-6-6"
214
+ stroke={colors.status.success}
215
+ strokeWidth="2"
216
+ strokeLinecap="round"
217
+ strokeLinejoin="round"
218
+ />
219
+ </svg>
220
+ </div>
221
+ </div>
222
+
223
+ {/* Title */}
224
+ <h2 style={styles.title}>Download Speech Recognition Model</h2>
225
+
226
+ {/* Description */}
227
+ <p style={styles.description}>
228
+ markupr needs to download a speech recognition model ({selectedModelInfo?.sizeMB || 75}MB)
229
+ to transcribe your voice offline. This is a one-time download.
230
+ </p>
231
+
232
+ {/* Model Selection (Advanced) */}
233
+ {showAdvanced && (
234
+ <div style={styles.modelSelector}>
235
+ <label style={styles.modelLabel}>Select Model:</label>
236
+ <div style={styles.modelOptions}>
237
+ {models.map((model) => (
238
+ <button
239
+ key={model.name}
240
+ style={{
241
+ ...styles.modelOption,
242
+ borderColor: selectedModel === model.name ? colors.accent.default : colors.bg.tertiary,
243
+ backgroundColor: selectedModel === model.name ? colors.accent.subtle : 'transparent',
244
+ }}
245
+ onClick={() => setSelectedModel(model.name)}
246
+ >
247
+ <div style={styles.modelOptionHeader}>
248
+ <span style={styles.modelName}>{model.name}</span>
249
+ {model.isDownloaded && (
250
+ <span style={styles.downloadedBadge}>Downloaded</span>
251
+ )}
252
+ </div>
253
+ <div style={styles.modelDetails}>
254
+ <span>{model.sizeMB}MB</span>
255
+ <span style={styles.modelQuality}>{model.quality}</span>
256
+ </div>
257
+ </button>
258
+ ))}
259
+ </div>
260
+ </div>
261
+ )}
262
+
263
+ {/* Toggle Advanced */}
264
+ <button
265
+ style={styles.advancedToggle}
266
+ onClick={() => setShowAdvanced(!showAdvanced)}
267
+ >
268
+ {showAdvanced ? 'Hide options' : 'Choose different model'}
269
+ </button>
270
+
271
+ {/* Action Buttons */}
272
+ <div style={styles.buttonGroup}>
273
+ <button style={styles.primaryButton} onClick={handleDownload}>
274
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" style={{ marginRight: 8 }}>
275
+ <path
276
+ d="M10 3v10m0 0l-3-3m3 3l3-3M4 17h12"
277
+ stroke="currentColor"
278
+ strokeWidth="2"
279
+ strokeLinecap="round"
280
+ strokeLinejoin="round"
281
+ />
282
+ </svg>
283
+ Download Now ({selectedModelInfo?.sizeMB || 75}MB)
284
+ </button>
285
+
286
+ <button style={styles.skipButton} onClick={onSkip}>
287
+ Skip for now (recording disabled)
288
+ </button>
289
+ </div>
290
+ </>
291
+ );
292
+
293
+ case 'downloading':
294
+ return (
295
+ <>
296
+ {/* Progress Illustration */}
297
+ <div style={styles.illustrationContainer}>
298
+ <div style={styles.progressCircleContainer}>
299
+ <svg width="120" height="120" viewBox="0 0 120 120">
300
+ {/* Background circle */}
301
+ <circle
302
+ cx="60"
303
+ cy="60"
304
+ r="54"
305
+ fill="none"
306
+ stroke={colors.bg.tertiary}
307
+ strokeWidth="8"
308
+ />
309
+ {/* Progress circle */}
310
+ <circle
311
+ cx="60"
312
+ cy="60"
313
+ r="54"
314
+ fill="none"
315
+ stroke={colors.accent.default}
316
+ strokeWidth="8"
317
+ strokeLinecap="round"
318
+ strokeDasharray={`${(progress?.percent || 0) * 3.39} 339`}
319
+ transform="rotate(-90 60 60)"
320
+ style={{ transition: 'stroke-dasharray 0.3s ease' }}
321
+ />
322
+ </svg>
323
+ <div style={styles.progressPercent}>{progress?.percent || 0}%</div>
324
+ </div>
325
+ </div>
326
+
327
+ {/* Title */}
328
+ <h2 style={styles.title}>Downloading Model...</h2>
329
+
330
+ {/* Progress Details */}
331
+ <div style={styles.progressDetails}>
332
+ <div style={styles.progressRow}>
333
+ <span style={styles.progressLabel}>Downloaded</span>
334
+ <span style={styles.progressValue}>
335
+ {formatBytes(progress?.downloadedBytes || 0)} / {formatBytes(progress?.totalBytes || 0)}
336
+ </span>
337
+ </div>
338
+ <div style={styles.progressRow}>
339
+ <span style={styles.progressLabel}>Speed</span>
340
+ <span style={styles.progressValue}>{formatSpeed(progress?.speedBps || 0)}</span>
341
+ </div>
342
+ <div style={styles.progressRow}>
343
+ <span style={styles.progressLabel}>Time remaining</span>
344
+ <span style={styles.progressValue}>
345
+ {formatTime(progress?.estimatedSecondsRemaining || 0)}
346
+ </span>
347
+ </div>
348
+ </div>
349
+
350
+ {/* Cancel Button */}
351
+ <button style={styles.cancelButton} onClick={handleCancel}>
352
+ Cancel Download
353
+ </button>
354
+ </>
355
+ );
356
+
357
+ case 'complete':
358
+ return (
359
+ <>
360
+ {/* Success Icon */}
361
+ <div style={styles.illustrationContainer}>
362
+ <div style={styles.successCircle}>
363
+ <svg width="48" height="48" viewBox="0 0 48 48" fill="none">
364
+ <path
365
+ d="M16 24l6 6 12-12"
366
+ stroke={colors.status.success}
367
+ strokeWidth="4"
368
+ strokeLinecap="round"
369
+ strokeLinejoin="round"
370
+ />
371
+ </svg>
372
+ </div>
373
+ </div>
374
+
375
+ {/* Title */}
376
+ <h2 style={{ ...styles.title, color: colors.status.success }}>Download Complete!</h2>
377
+
378
+ {/* Description */}
379
+ <p style={styles.description}>
380
+ The speech recognition model has been downloaded successfully.
381
+ markupr can now transcribe your voice offline.
382
+ </p>
383
+
384
+ {/* Continue Button */}
385
+ <button style={styles.successButton} onClick={onComplete}>
386
+ Start Using markupr
387
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" style={{ marginLeft: 8 }}>
388
+ <path
389
+ d="M7.5 15l5-5-5-5"
390
+ stroke="currentColor"
391
+ strokeWidth="2"
392
+ strokeLinecap="round"
393
+ strokeLinejoin="round"
394
+ />
395
+ </svg>
396
+ </button>
397
+ </>
398
+ );
399
+
400
+ case 'error':
401
+ return (
402
+ <>
403
+ {/* Error Icon */}
404
+ <div style={styles.illustrationContainer}>
405
+ <div style={styles.errorCircle}>
406
+ <svg width="48" height="48" viewBox="0 0 48 48" fill="none">
407
+ <path
408
+ d="M24 16v8m0 8h.01"
409
+ stroke={colors.status.error}
410
+ strokeWidth="4"
411
+ strokeLinecap="round"
412
+ />
413
+ </svg>
414
+ </div>
415
+ </div>
416
+
417
+ {/* Title */}
418
+ <h2 style={{ ...styles.title, color: colors.status.error }}>Download Failed</h2>
419
+
420
+ {/* Error Message */}
421
+ <div style={styles.errorBox}>
422
+ <span>{error}</span>
423
+ </div>
424
+
425
+ {/* Retry Button */}
426
+ <div style={styles.buttonGroup}>
427
+ <button style={styles.primaryButton} onClick={handleDownload}>
428
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" style={{ marginRight: 8 }}>
429
+ <path
430
+ d="M4 10a6 6 0 1 1 12 0m-6-6v6m0 0l-2-2m2 2l2-2"
431
+ stroke="currentColor"
432
+ strokeWidth="2"
433
+ strokeLinecap="round"
434
+ strokeLinejoin="round"
435
+ />
436
+ </svg>
437
+ Try Again
438
+ </button>
439
+
440
+ <button style={styles.skipButton} onClick={onSkip}>
441
+ Skip for now
442
+ </button>
443
+ </div>
444
+ </>
445
+ );
446
+ }
447
+ };
448
+
449
+ return (
450
+ <div style={styles.overlay}>
451
+ <div style={styles.backdrop} />
452
+ <div style={styles.modal}>
453
+ <div style={styles.content}>{renderContent()}</div>
454
+ </div>
455
+ </div>
456
+ );
457
+ };
458
+
459
+ // ============================================================================
460
+ // useModelCheck Hook - Check if model download is needed
461
+ // ============================================================================
462
+
463
+ interface ModelCheckResult {
464
+ isChecking: boolean;
465
+ needsDownload: boolean;
466
+ hasTranscriptionCapability: boolean;
467
+ }
468
+
469
+ export function useModelCheck(): ModelCheckResult {
470
+ const [isChecking, setIsChecking] = useState(true);
471
+ const [needsDownload, setNeedsDownload] = useState(false);
472
+ const [hasTranscriptionCapability, setHasTranscriptionCapability] = useState(false);
473
+
474
+ useEffect(() => {
475
+ let isMounted = true;
476
+ const safetyTimeout = window.setTimeout(() => {
477
+ if (!isMounted) return;
478
+ console.warn('[useModelCheck] Capability check timed out, continuing startup.');
479
+ setIsChecking(false);
480
+ }, 7000);
481
+
482
+ const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number, fallback: T): Promise<T> => {
483
+ let timeoutId: number | null = null;
484
+ const timeoutPromise = new Promise<T>((resolve) => {
485
+ timeoutId = window.setTimeout(() => resolve(fallback), timeoutMs);
486
+ });
487
+
488
+ const result = await Promise.race([promise, timeoutPromise]);
489
+ if (timeoutId !== null) {
490
+ window.clearTimeout(timeoutId);
491
+ }
492
+ return result;
493
+ };
494
+
495
+ const checkModel = async () => {
496
+ try {
497
+ // Check if we have any transcription capability (OpenAI or Whisper)
498
+ const hasCapability = await withTimeout(
499
+ window.markupr.whisper.hasTranscriptionCapability(),
500
+ 4000,
501
+ false
502
+ );
503
+ if (!isMounted) return;
504
+ setHasTranscriptionCapability(hasCapability);
505
+
506
+ if (!hasCapability) {
507
+ // Check specifically if we have a Whisper model
508
+ const modelCheck = await withTimeout(
509
+ window.markupr.whisper.checkModel(),
510
+ 4000,
511
+ {
512
+ hasAnyModel: false,
513
+ defaultModel: null,
514
+ downloadedModels: [],
515
+ recommendedModel: 'tiny',
516
+ recommendedModelSizeMB: 75,
517
+ }
518
+ );
519
+ if (!isMounted) return;
520
+ setNeedsDownload(!modelCheck.hasAnyModel);
521
+ } else {
522
+ setNeedsDownload(false);
523
+ }
524
+ } catch (error) {
525
+ if (!isMounted) return;
526
+ console.error('[useModelCheck] Failed to check model status:', error);
527
+ setNeedsDownload(true);
528
+ } finally {
529
+ window.clearTimeout(safetyTimeout);
530
+ if (isMounted) {
531
+ setIsChecking(false);
532
+ }
533
+ }
534
+ };
535
+
536
+ checkModel();
537
+
538
+ return () => {
539
+ isMounted = false;
540
+ window.clearTimeout(safetyTimeout);
541
+ };
542
+ }, []);
543
+
544
+ return { isChecking, needsDownload, hasTranscriptionCapability };
545
+ }
546
+
547
+ // ============================================================================
548
+ // Styles
549
+ // ============================================================================
550
+
551
+ type ExtendedCSSProperties = React.CSSProperties & {
552
+ WebkitAppRegion?: 'drag' | 'no-drag';
553
+ };
554
+
555
+ const styles: Record<string, ExtendedCSSProperties> = {
556
+ overlay: {
557
+ position: 'fixed',
558
+ inset: 0,
559
+ display: 'flex',
560
+ alignItems: 'center',
561
+ justifyContent: 'center',
562
+ zIndex: 50,
563
+ // Solid background to work with transparent Electron window
564
+ background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 100%)',
565
+ },
566
+
567
+ backdrop: {
568
+ position: 'absolute',
569
+ inset: 0,
570
+ // Keep backdrop for additional depth but make it subtle
571
+ backgroundColor: 'rgba(0, 0, 0, 0.3)',
572
+ backdropFilter: 'blur(8px)',
573
+ WebkitBackdropFilter: 'blur(8px)',
574
+ },
575
+
576
+ modal: {
577
+ position: 'relative',
578
+ width: '100%',
579
+ maxWidth: 420,
580
+ margin: 24,
581
+ // Fully opaque background for the modal
582
+ backgroundColor: 'rgb(17, 24, 39)',
583
+ borderRadius: 24,
584
+ boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1)',
585
+ overflow: 'hidden',
586
+ WebkitAppRegion: 'no-drag',
587
+ },
588
+
589
+ content: {
590
+ padding: '40px 32px',
591
+ display: 'flex',
592
+ flexDirection: 'column',
593
+ alignItems: 'center',
594
+ textAlign: 'center',
595
+ overflowY: 'auto',
596
+ },
597
+
598
+ illustrationContainer: {
599
+ marginBottom: 24,
600
+ },
601
+
602
+ iconCircle: {
603
+ width: 96,
604
+ height: 96,
605
+ borderRadius: '50%',
606
+ backgroundColor: 'var(--accent-subtle)',
607
+ border: '2px solid var(--accent-default)',
608
+ display: 'flex',
609
+ alignItems: 'center',
610
+ justifyContent: 'center',
611
+ },
612
+
613
+ title: {
614
+ fontSize: 22,
615
+ fontWeight: 600,
616
+ color: 'var(--text-primary)',
617
+ marginBottom: 12,
618
+ letterSpacing: '-0.01em',
619
+ },
620
+
621
+ description: {
622
+ fontSize: 14,
623
+ lineHeight: 1.6,
624
+ color: 'var(--text-secondary)',
625
+ marginBottom: 24,
626
+ maxWidth: 340,
627
+ },
628
+
629
+ buttonGroup: {
630
+ display: 'flex',
631
+ flexDirection: 'column',
632
+ gap: 12,
633
+ width: '100%',
634
+ maxWidth: 300,
635
+ },
636
+
637
+ primaryButton: {
638
+ display: 'flex',
639
+ alignItems: 'center',
640
+ justifyContent: 'center',
641
+ width: '100%',
642
+ padding: '14px 24px',
643
+ backgroundColor: 'var(--accent-default)',
644
+ border: 'none',
645
+ borderRadius: 12,
646
+ color: 'var(--text-inverse)',
647
+ fontSize: 15,
648
+ fontWeight: 600,
649
+ cursor: 'pointer',
650
+ transition: 'all 0.2s ease',
651
+ },
652
+
653
+ skipButton: {
654
+ padding: '10px 16px',
655
+ backgroundColor: 'transparent',
656
+ border: 'none',
657
+ color: 'var(--text-tertiary)',
658
+ fontSize: 13,
659
+ cursor: 'pointer',
660
+ transition: 'color 0.2s ease',
661
+ },
662
+
663
+ advancedToggle: {
664
+ marginBottom: 24,
665
+ padding: '8px 16px',
666
+ backgroundColor: 'transparent',
667
+ border: 'none',
668
+ color: 'var(--text-link)',
669
+ fontSize: 13,
670
+ cursor: 'pointer',
671
+ transition: 'color 0.2s ease',
672
+ },
673
+
674
+ modelSelector: {
675
+ width: '100%',
676
+ marginBottom: 16,
677
+ },
678
+
679
+ modelLabel: {
680
+ display: 'block',
681
+ fontSize: 13,
682
+ color: 'var(--text-secondary)',
683
+ marginBottom: 12,
684
+ textAlign: 'left',
685
+ },
686
+
687
+ modelOptions: {
688
+ display: 'flex',
689
+ flexDirection: 'column',
690
+ gap: 8,
691
+ },
692
+
693
+ modelOption: {
694
+ width: '100%',
695
+ padding: '12px 16px',
696
+ backgroundColor: 'transparent',
697
+ border: '1px solid var(--bg-tertiary)',
698
+ borderRadius: 10,
699
+ cursor: 'pointer',
700
+ transition: 'all 0.2s ease',
701
+ textAlign: 'left',
702
+ },
703
+
704
+ modelOptionHeader: {
705
+ display: 'flex',
706
+ alignItems: 'center',
707
+ justifyContent: 'space-between',
708
+ marginBottom: 4,
709
+ },
710
+
711
+ modelName: {
712
+ fontSize: 14,
713
+ fontWeight: 600,
714
+ color: 'var(--text-primary)',
715
+ textTransform: 'capitalize',
716
+ },
717
+
718
+ downloadedBadge: {
719
+ fontSize: 11,
720
+ padding: '2px 8px',
721
+ backgroundColor: 'var(--status-success-subtle)',
722
+ color: 'var(--status-success)',
723
+ borderRadius: 4,
724
+ },
725
+
726
+ modelDetails: {
727
+ display: 'flex',
728
+ gap: 12,
729
+ fontSize: 12,
730
+ color: 'var(--text-tertiary)',
731
+ },
732
+
733
+ modelQuality: {
734
+ color: 'var(--text-secondary)',
735
+ },
736
+
737
+ // Progress state styles
738
+ progressCircleContainer: {
739
+ position: 'relative',
740
+ width: 120,
741
+ height: 120,
742
+ display: 'flex',
743
+ alignItems: 'center',
744
+ justifyContent: 'center',
745
+ },
746
+
747
+ progressPercent: {
748
+ position: 'absolute',
749
+ fontSize: 24,
750
+ fontWeight: 700,
751
+ color: 'var(--text-primary)',
752
+ },
753
+
754
+ progressDetails: {
755
+ width: '100%',
756
+ maxWidth: 280,
757
+ marginBottom: 24,
758
+ },
759
+
760
+ progressRow: {
761
+ display: 'flex',
762
+ justifyContent: 'space-between',
763
+ padding: '8px 0',
764
+ borderBottom: '1px solid rgba(255, 255, 255, 0.05)',
765
+ },
766
+
767
+ progressLabel: {
768
+ fontSize: 13,
769
+ color: 'var(--text-tertiary)',
770
+ },
771
+
772
+ progressValue: {
773
+ fontSize: 13,
774
+ color: 'var(--text-primary)',
775
+ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
776
+ },
777
+
778
+ cancelButton: {
779
+ padding: '12px 24px',
780
+ backgroundColor: 'transparent',
781
+ border: '1px solid var(--border-strong)',
782
+ borderRadius: 10,
783
+ color: 'var(--text-secondary)',
784
+ fontSize: 14,
785
+ cursor: 'pointer',
786
+ transition: 'all 0.2s ease',
787
+ },
788
+
789
+ // Success state styles
790
+ successCircle: {
791
+ width: 96,
792
+ height: 96,
793
+ borderRadius: '50%',
794
+ backgroundColor: 'var(--status-success-subtle)',
795
+ border: '2px solid var(--status-success)',
796
+ display: 'flex',
797
+ alignItems: 'center',
798
+ justifyContent: 'center',
799
+ },
800
+
801
+ successButton: {
802
+ display: 'flex',
803
+ alignItems: 'center',
804
+ justifyContent: 'center',
805
+ width: '100%',
806
+ maxWidth: 300,
807
+ padding: '14px 24px',
808
+ background: 'linear-gradient(135deg, var(--status-success) 0%, #059669 100%)',
809
+ border: 'none',
810
+ borderRadius: 12,
811
+ color: 'var(--text-inverse)',
812
+ fontSize: 15,
813
+ fontWeight: 600,
814
+ cursor: 'pointer',
815
+ transition: 'all 0.2s ease',
816
+ },
817
+
818
+ // Error state styles
819
+ errorCircle: {
820
+ width: 96,
821
+ height: 96,
822
+ borderRadius: '50%',
823
+ backgroundColor: 'var(--status-error-subtle)',
824
+ border: '2px solid var(--status-error)',
825
+ display: 'flex',
826
+ alignItems: 'center',
827
+ justifyContent: 'center',
828
+ },
829
+
830
+ errorBox: {
831
+ width: '100%',
832
+ maxWidth: 300,
833
+ padding: 12,
834
+ backgroundColor: 'var(--status-error-subtle)',
835
+ border: '1px solid rgba(239, 68, 68, 0.3)',
836
+ borderRadius: 8,
837
+ marginBottom: 24,
838
+ fontSize: 13,
839
+ color: 'var(--status-error)',
840
+ textAlign: 'center',
841
+ },
842
+ };
843
+
844
+ export default ModelDownloadDialog;