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