markupr 2.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/review-feedback.md +47 -0
- package/.eslintrc.json +35 -0
- package/.github/CODEOWNERS +16 -0
- package/.github/FUNDING.yml +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +56 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +54 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +89 -0
- package/.github/dependabot.yml +70 -0
- package/.github/workflows/ci.yml +184 -0
- package/.github/workflows/deploy-landing.yml +134 -0
- package/.github/workflows/nightly.yml +288 -0
- package/.github/workflows/release.yml +318 -0
- package/CHANGELOG.md +127 -0
- package/CLAUDE.md +137 -0
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +390 -0
- package/LICENSE +21 -0
- package/PRODUCT_VISION.md +277 -0
- package/README.md +517 -0
- package/SECURITY.md +51 -0
- package/SIGNING_INSTRUCTIONS.md +284 -0
- package/assets/DMG_BACKGROUND_INSTRUCTIONS.md +130 -0
- package/assets/svg-source/dmg-background.svg +70 -0
- package/assets/svg-source/icon.svg +20 -0
- package/assets/svg-source/tray-icon-processing.svg +7 -0
- package/assets/svg-source/tray-icon-recording.svg +7 -0
- package/assets/svg-source/tray-icon.svg +6 -0
- 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 +50 -0
- package/build/dmg-background.png +0 -0
- package/build/dmg-background@2x.png +0 -0
- package/build/entitlements.mac.inherit.plist +27 -0
- package/build/entitlements.mac.plist +41 -0
- 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 +45 -0
- 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/main/index.mjs +12612 -0
- package/dist/preload/index.mjs +907 -0
- package/dist/renderer/assets/index-CCmUjl9K.js +19495 -0
- package/dist/renderer/assets/index-CUqz_Gs6.css +2270 -0
- package/dist/renderer/index.html +27 -0
- package/docs/AI_AGENT_QUICKSTART.md +42 -0
- package/docs/AI_PIPELINE_DESIGN.md +595 -0
- package/docs/API.md +514 -0
- package/docs/ARCHITECTURE.md +460 -0
- package/docs/CONFIGURATION.md +336 -0
- package/docs/DEVELOPMENT.md +508 -0
- package/docs/EXPORT_FORMATS.md +451 -0
- package/docs/GETTING_STARTED.md +236 -0
- package/docs/KEYBOARD_SHORTCUTS.md +334 -0
- package/docs/TROUBLESHOOTING.md +418 -0
- package/docs/landing/index.html +672 -0
- package/docs/landing/script.js +342 -0
- package/docs/landing/styles.css +1543 -0
- package/electron-builder.yml +140 -0
- package/electron.vite.config.ts +63 -0
- package/package.json +108 -0
- package/railway.json +12 -0
- package/scripts/build.mjs +51 -0
- package/scripts/generate-icons.mjs +314 -0
- package/scripts/generate-installer-images.cjs +253 -0
- package/scripts/generate-tray-icons.mjs +258 -0
- package/scripts/notarize.cjs +180 -0
- package/scripts/one-click-clean-test.sh +147 -0
- package/scripts/postinstall.mjs +36 -0
- package/scripts/setup-markupr.sh +55 -0
- package/setup +17 -0
- package/site/index.html +1835 -0
- package/site/package.json +11 -0
- package/site/railway.json +12 -0
- package/site/server.js +31 -0
- package/src/main/AutoUpdater.ts +392 -0
- package/src/main/CrashRecovery.ts +655 -0
- package/src/main/ErrorHandler.ts +703 -0
- package/src/main/HotkeyManager.ts +399 -0
- package/src/main/MenuManager.ts +529 -0
- package/src/main/PermissionManager.ts +420 -0
- package/src/main/SessionController.ts +1465 -0
- package/src/main/TrayManager.ts +540 -0
- package/src/main/ai/AIPipelineManager.ts +199 -0
- package/src/main/ai/ClaudeAnalyzer.ts +339 -0
- package/src/main/ai/ImageOptimizer.ts +176 -0
- package/src/main/ai/StructuredMarkdownBuilder.ts +379 -0
- package/src/main/ai/index.ts +16 -0
- package/src/main/ai/types.ts +258 -0
- package/src/main/analysis/ClarificationGenerator.ts +385 -0
- package/src/main/analysis/FeedbackAnalyzer.ts +531 -0
- package/src/main/analysis/index.ts +19 -0
- package/src/main/audio/AudioCapture.ts +978 -0
- package/src/main/audio/audioUtils.ts +100 -0
- package/src/main/audio/index.ts +20 -0
- package/src/main/capture/index.ts +1 -0
- package/src/main/index.ts +1693 -0
- package/src/main/ipc/captureHandlers.ts +272 -0
- package/src/main/ipc/index.ts +45 -0
- package/src/main/ipc/outputHandlers.ts +302 -0
- package/src/main/ipc/sessionHandlers.ts +56 -0
- package/src/main/ipc/settingsHandlers.ts +471 -0
- package/src/main/ipc/types.ts +56 -0
- package/src/main/ipc/windowHandlers.ts +277 -0
- package/src/main/output/ClipboardService.ts +369 -0
- package/src/main/output/ExportService.ts +539 -0
- package/src/main/output/FileManager.ts +416 -0
- package/src/main/output/MarkdownGenerator.ts +791 -0
- package/src/main/output/MarkdownPatcher.ts +299 -0
- package/src/main/output/index.ts +186 -0
- package/src/main/output/sessionAdapter.ts +207 -0
- package/src/main/output/templates/html-template.ts +553 -0
- package/src/main/pipeline/FrameExtractor.ts +330 -0
- package/src/main/pipeline/PostProcessor.ts +399 -0
- package/src/main/pipeline/TranscriptAnalyzer.ts +226 -0
- package/src/main/pipeline/index.ts +36 -0
- package/src/main/platform/WindowsTaskbar.ts +600 -0
- package/src/main/platform/index.ts +16 -0
- package/src/main/settings/SettingsManager.ts +730 -0
- package/src/main/settings/index.ts +19 -0
- package/src/main/transcription/ModelDownloadManager.ts +494 -0
- package/src/main/transcription/TierManager.ts +219 -0
- package/src/main/transcription/TranscriptionRecoveryService.ts +340 -0
- package/src/main/transcription/WhisperService.ts +748 -0
- package/src/main/transcription/index.ts +56 -0
- package/src/main/transcription/types.ts +135 -0
- package/src/main/windows/PopoverManager.ts +284 -0
- package/src/main/windows/TaskbarIntegration.ts +452 -0
- package/src/main/windows/index.ts +23 -0
- package/src/preload/index.ts +1047 -0
- package/src/renderer/App.tsx +515 -0
- package/src/renderer/AppWrapper.tsx +28 -0
- package/src/renderer/assets/logo-dark.svg +7 -0
- package/src/renderer/assets/logo.svg +7 -0
- package/src/renderer/audio/AudioCaptureRenderer.ts +454 -0
- package/src/renderer/capture/ScreenRecordingRenderer.ts +492 -0
- package/src/renderer/components/AnnotationOverlay.tsx +836 -0
- package/src/renderer/components/AudioWaveform.tsx +811 -0
- package/src/renderer/components/ClarificationQuestions.tsx +656 -0
- package/src/renderer/components/CountdownTimer.tsx +495 -0
- package/src/renderer/components/CrashRecoveryDialog.tsx +632 -0
- package/src/renderer/components/DonateButton.tsx +127 -0
- package/src/renderer/components/ErrorBoundary.tsx +308 -0
- package/src/renderer/components/ExportDialog.tsx +872 -0
- package/src/renderer/components/HotkeyHint.tsx +261 -0
- package/src/renderer/components/KeyboardShortcuts.tsx +787 -0
- package/src/renderer/components/ModelDownloadDialog.tsx +844 -0
- package/src/renderer/components/Onboarding.tsx +1830 -0
- package/src/renderer/components/ProcessingOverlay.tsx +157 -0
- package/src/renderer/components/RecordingOverlay.tsx +423 -0
- package/src/renderer/components/SessionHistory.tsx +1746 -0
- package/src/renderer/components/SessionReview.tsx +1321 -0
- package/src/renderer/components/SettingsPanel.tsx +217 -0
- package/src/renderer/components/Skeleton.tsx +347 -0
- package/src/renderer/components/StatusIndicator.tsx +86 -0
- package/src/renderer/components/ThemeProvider.tsx +429 -0
- package/src/renderer/components/Tooltip.tsx +370 -0
- package/src/renderer/components/TranscriptionPreview.tsx +183 -0
- package/src/renderer/components/TranscriptionTierSelector.tsx +640 -0
- package/src/renderer/components/UpdateNotification.tsx +377 -0
- package/src/renderer/components/WindowSelector.tsx +947 -0
- package/src/renderer/components/index.ts +99 -0
- package/src/renderer/components/primitives/ApiKeyInput.tsx +98 -0
- package/src/renderer/components/primitives/ColorPicker.tsx +65 -0
- package/src/renderer/components/primitives/DangerButton.tsx +45 -0
- package/src/renderer/components/primitives/DirectoryPicker.tsx +41 -0
- package/src/renderer/components/primitives/Dropdown.tsx +34 -0
- package/src/renderer/components/primitives/KeyRecorder.tsx +117 -0
- package/src/renderer/components/primitives/SettingsSection.tsx +32 -0
- package/src/renderer/components/primitives/Slider.tsx +43 -0
- package/src/renderer/components/primitives/Toggle.tsx +36 -0
- package/src/renderer/components/primitives/index.ts +10 -0
- package/src/renderer/components/settings/AdvancedTab.tsx +174 -0
- package/src/renderer/components/settings/AppearanceTab.tsx +77 -0
- package/src/renderer/components/settings/GeneralTab.tsx +40 -0
- package/src/renderer/components/settings/HotkeysTab.tsx +79 -0
- package/src/renderer/components/settings/RecordingTab.tsx +84 -0
- package/src/renderer/components/settings/index.ts +9 -0
- package/src/renderer/components/settings/settingsStyles.ts +673 -0
- package/src/renderer/components/settings/tabConfig.tsx +85 -0
- package/src/renderer/components/settings/useSettingsPanel.ts +447 -0
- package/src/renderer/contexts/ProcessingContext.tsx +227 -0
- package/src/renderer/contexts/RecordingContext.tsx +683 -0
- package/src/renderer/contexts/UIContext.tsx +326 -0
- package/src/renderer/contexts/index.ts +24 -0
- package/src/renderer/donateMessages.ts +69 -0
- package/src/renderer/hooks/index.ts +75 -0
- package/src/renderer/hooks/useAnimation.tsx +544 -0
- package/src/renderer/hooks/useTheme.ts +313 -0
- package/src/renderer/index.html +26 -0
- package/src/renderer/main.tsx +52 -0
- package/src/renderer/styles/animations.css +1093 -0
- package/src/renderer/styles/app-shell.css +662 -0
- package/src/renderer/styles/globals.css +515 -0
- package/src/renderer/styles/theme.ts +578 -0
- package/src/renderer/types/electron.d.ts +385 -0
- package/src/shared/hotkeys.ts +283 -0
- package/src/shared/types.ts +809 -0
- package/tests/clipboard.test.ts +228 -0
- package/tests/e2e/criticalPaths.test.ts +594 -0
- package/tests/feedbackAnalyzer.test.ts +303 -0
- package/tests/integration/sessionFlow.test.ts +583 -0
- package/tests/markdownGenerator.test.ts +418 -0
- package/tests/output.test.ts +96 -0
- package/tests/setup.ts +486 -0
- package/tests/unit/appIntegration.test.ts +676 -0
- package/tests/unit/appViewState.test.ts +281 -0
- package/tests/unit/audioIpcChannels.test.ts +17 -0
- package/tests/unit/exportService.test.ts +492 -0
- package/tests/unit/hotkeys.test.ts +92 -0
- package/tests/unit/navigationPreload.test.ts +94 -0
- package/tests/unit/onboardingFlow.test.ts +345 -0
- package/tests/unit/permissionManager.test.ts +175 -0
- package/tests/unit/permissionManagerExpanded.test.ts +296 -0
- package/tests/unit/screenRecordingRenderer.test.ts +368 -0
- package/tests/unit/sessionController.test.ts +515 -0
- package/tests/unit/tierManager.test.ts +61 -0
- package/tests/unit/tierManagerExpanded.test.ts +142 -0
- package/tests/unit/transcriptAnalyzer.test.ts +64 -0
- package/tsconfig.json +25 -0
- package/vitest.config.ts +46 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Window IPC Handlers
|
|
3
|
+
*
|
|
4
|
+
* Registers IPC handlers for window control, popover management,
|
|
5
|
+
* app version, Windows taskbar, transcription tiers, and Whisper model management.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ipcMain, app } from 'electron';
|
|
9
|
+
import { tierManager } from '../transcription/TierManager';
|
|
10
|
+
import { modelDownloadManager } from '../transcription/ModelDownloadManager';
|
|
11
|
+
import type { WhisperModel } from '../transcription/types';
|
|
12
|
+
import { POPOVER_SIZES } from '../windows';
|
|
13
|
+
import {
|
|
14
|
+
IPC_CHANNELS,
|
|
15
|
+
type TranscriptionTier as UiTranscriptionTier,
|
|
16
|
+
type TranscriptionTierStatus,
|
|
17
|
+
} from '../../shared/types';
|
|
18
|
+
import type { IpcContext } from './types';
|
|
19
|
+
|
|
20
|
+
export function registerWindowHandlers(ctx: IpcContext): void {
|
|
21
|
+
const { getMainWindow, getPopover, getWindowsTaskbar } = ctx;
|
|
22
|
+
|
|
23
|
+
// -------------------------------------------------------------------------
|
|
24
|
+
// App Version
|
|
25
|
+
// -------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
ipcMain.handle(IPC_CHANNELS.APP_VERSION, () => {
|
|
28
|
+
return app.getVersion();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// -------------------------------------------------------------------------
|
|
32
|
+
// Window Control
|
|
33
|
+
// -------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
ipcMain.handle(IPC_CHANNELS.WINDOW_MINIMIZE, () => {
|
|
36
|
+
getMainWindow()?.minimize();
|
|
37
|
+
return { success: true };
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
ipcMain.handle(IPC_CHANNELS.WINDOW_HIDE, () => {
|
|
41
|
+
const popover = getPopover();
|
|
42
|
+
if (popover) {
|
|
43
|
+
popover.hide();
|
|
44
|
+
} else {
|
|
45
|
+
getMainWindow()?.hide();
|
|
46
|
+
}
|
|
47
|
+
return { success: true };
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
ipcMain.handle(IPC_CHANNELS.WINDOW_CLOSE, () => {
|
|
51
|
+
getMainWindow()?.close();
|
|
52
|
+
return { success: true };
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// -------------------------------------------------------------------------
|
|
56
|
+
// Popover Control (Menu Bar Mode)
|
|
57
|
+
// -------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
ipcMain.handle(IPC_CHANNELS.POPOVER_RESIZE, (_, width: number, height: number) => {
|
|
60
|
+
const popover = getPopover();
|
|
61
|
+
if (popover) {
|
|
62
|
+
popover.resize(width, height);
|
|
63
|
+
return { success: true };
|
|
64
|
+
}
|
|
65
|
+
return { success: false, error: 'Popover not initialized' };
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
ipcMain.handle(IPC_CHANNELS.POPOVER_RESIZE_TO_STATE, (_, state: string) => {
|
|
69
|
+
const popover = getPopover();
|
|
70
|
+
if (popover && state in POPOVER_SIZES) {
|
|
71
|
+
popover.resizeToState(state as keyof typeof POPOVER_SIZES);
|
|
72
|
+
return { success: true };
|
|
73
|
+
}
|
|
74
|
+
return { success: false, error: 'Popover not initialized or invalid state' };
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
ipcMain.handle(IPC_CHANNELS.POPOVER_SHOW, () => {
|
|
78
|
+
getPopover()?.show();
|
|
79
|
+
return { success: true };
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
ipcMain.handle(IPC_CHANNELS.POPOVER_HIDE, () => {
|
|
83
|
+
getPopover()?.hide();
|
|
84
|
+
return { success: true };
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
ipcMain.handle(IPC_CHANNELS.POPOVER_TOGGLE, () => {
|
|
88
|
+
getPopover()?.toggle();
|
|
89
|
+
return { success: true };
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// -------------------------------------------------------------------------
|
|
93
|
+
// Windows Taskbar (Windows-specific)
|
|
94
|
+
// -------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
ipcMain.handle(
|
|
97
|
+
IPC_CHANNELS.TASKBAR_SET_PROGRESS,
|
|
98
|
+
(_, progress: number) => {
|
|
99
|
+
getWindowsTaskbar()?.setProgress(progress);
|
|
100
|
+
return { success: true };
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
ipcMain.handle(
|
|
105
|
+
IPC_CHANNELS.TASKBAR_FLASH_FRAME,
|
|
106
|
+
(_, count?: number) => {
|
|
107
|
+
getWindowsTaskbar()?.flashFrame(count);
|
|
108
|
+
return { success: true };
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
ipcMain.handle(
|
|
113
|
+
IPC_CHANNELS.TASKBAR_SET_OVERLAY,
|
|
114
|
+
(_, state: 'recording' | 'processing' | 'none') => {
|
|
115
|
+
getWindowsTaskbar()?.setOverlayIcon(state);
|
|
116
|
+
return { success: true };
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// -------------------------------------------------------------------------
|
|
121
|
+
// Transcription Tier Control
|
|
122
|
+
// -------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
ipcMain.handle(
|
|
125
|
+
IPC_CHANNELS.TRANSCRIPTION_GET_TIER_STATUSES,
|
|
126
|
+
async (): Promise<TranscriptionTierStatus[]> => {
|
|
127
|
+
const statuses = await tierManager.getTierStatuses();
|
|
128
|
+
|
|
129
|
+
return statuses.map((status) => {
|
|
130
|
+
if (tierManager.tierProvidesTranscription(status.tier)) {
|
|
131
|
+
return status;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
...status,
|
|
136
|
+
available: false,
|
|
137
|
+
reason: 'Not supported for narrated feedback reports',
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
ipcMain.handle(
|
|
144
|
+
IPC_CHANNELS.TRANSCRIPTION_GET_CURRENT_TIER,
|
|
145
|
+
async (): Promise<UiTranscriptionTier | null> => {
|
|
146
|
+
const preferred = tierManager.getPreferredTier();
|
|
147
|
+
if (preferred !== 'auto') {
|
|
148
|
+
return preferred;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const active = tierManager.getCurrentTier();
|
|
152
|
+
if (active) {
|
|
153
|
+
return active;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const best = await tierManager.selectBestTier();
|
|
157
|
+
if (tierManager.tierProvidesTranscription(best)) {
|
|
158
|
+
return best;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
ipcMain.handle(
|
|
166
|
+
IPC_CHANNELS.TRANSCRIPTION_SET_TIER,
|
|
167
|
+
(_, tier: UiTranscriptionTier): { success: boolean; error?: string } => {
|
|
168
|
+
try {
|
|
169
|
+
const validTiers = new Set(['auto', 'whisper', 'timer-only']);
|
|
170
|
+
if (!validTiers.has(tier)) {
|
|
171
|
+
return { success: false, error: `Tier "${tier}" is no longer supported.` };
|
|
172
|
+
}
|
|
173
|
+
tierManager.setPreferredTier(tier as 'auto' | 'whisper' | 'timer-only');
|
|
174
|
+
return { success: true };
|
|
175
|
+
} catch (error) {
|
|
176
|
+
return {
|
|
177
|
+
success: false,
|
|
178
|
+
error: error instanceof Error ? error.message : 'Failed to set transcription tier.',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// -------------------------------------------------------------------------
|
|
185
|
+
// Whisper Model Channels
|
|
186
|
+
// -------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
ipcMain.handle(IPC_CHANNELS.WHISPER_CHECK_MODEL, () => {
|
|
189
|
+
const hasAnyModel = modelDownloadManager.hasAnyModel();
|
|
190
|
+
const downloadedModels: string[] = [];
|
|
191
|
+
const models: WhisperModel[] = ['tiny', 'base', 'small', 'medium', 'large'];
|
|
192
|
+
|
|
193
|
+
for (const model of models) {
|
|
194
|
+
if (modelDownloadManager.isModelDownloaded(model)) {
|
|
195
|
+
downloadedModels.push(model);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const defaultModel = hasAnyModel ? modelDownloadManager.getDefaultModel() : null;
|
|
200
|
+
const recommendedModel = 'tiny';
|
|
201
|
+
const recommendedInfo = modelDownloadManager.getModelInfo('tiny');
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
hasAnyModel,
|
|
205
|
+
defaultModel,
|
|
206
|
+
downloadedModels,
|
|
207
|
+
recommendedModel,
|
|
208
|
+
recommendedModelSizeMB: recommendedInfo.sizeMB,
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
ipcMain.handle(IPC_CHANNELS.WHISPER_HAS_TRANSCRIPTION_CAPABILITY, async () => {
|
|
213
|
+
return tierManager.hasTranscriptionCapability();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
ipcMain.handle(IPC_CHANNELS.WHISPER_GET_AVAILABLE_MODELS, () => {
|
|
217
|
+
const models = modelDownloadManager.getAvailableModels();
|
|
218
|
+
return models.map((info) => ({
|
|
219
|
+
name: info.name,
|
|
220
|
+
filename: info.filename,
|
|
221
|
+
sizeMB: info.sizeMB,
|
|
222
|
+
ramRequired: info.ramRequired,
|
|
223
|
+
quality: info.quality,
|
|
224
|
+
isDownloaded: modelDownloadManager.isModelDownloaded(info.name as WhisperModel),
|
|
225
|
+
}));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
ipcMain.handle(IPC_CHANNELS.WHISPER_DOWNLOAD_MODEL, async (_, model: WhisperModel) => {
|
|
229
|
+
try {
|
|
230
|
+
const unsubProgress = modelDownloadManager.onProgress((progress) => {
|
|
231
|
+
getMainWindow()?.webContents.send(IPC_CHANNELS.WHISPER_DOWNLOAD_PROGRESS, {
|
|
232
|
+
model: progress.model,
|
|
233
|
+
downloadedBytes: progress.downloadedBytes,
|
|
234
|
+
totalBytes: progress.totalBytes,
|
|
235
|
+
percent: progress.percent,
|
|
236
|
+
speedBps: progress.speedBps,
|
|
237
|
+
estimatedSecondsRemaining: progress.estimatedSecondsRemaining,
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const unsubComplete = modelDownloadManager.onComplete((result) => {
|
|
242
|
+
getMainWindow()?.webContents.send(IPC_CHANNELS.WHISPER_DOWNLOAD_COMPLETE, {
|
|
243
|
+
model: result.model,
|
|
244
|
+
path: result.path,
|
|
245
|
+
});
|
|
246
|
+
unsubProgress();
|
|
247
|
+
unsubComplete();
|
|
248
|
+
unsubError();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const unsubError = modelDownloadManager.onError((error, errorModel) => {
|
|
252
|
+
getMainWindow()?.webContents.send(IPC_CHANNELS.WHISPER_DOWNLOAD_ERROR, {
|
|
253
|
+
model: errorModel,
|
|
254
|
+
error: error.message,
|
|
255
|
+
});
|
|
256
|
+
unsubProgress();
|
|
257
|
+
unsubComplete();
|
|
258
|
+
unsubError();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const result = await modelDownloadManager.downloadModel(model);
|
|
262
|
+
|
|
263
|
+
return { success: result.success };
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.error('[Main] Failed to download Whisper model:', error);
|
|
266
|
+
return {
|
|
267
|
+
success: false,
|
|
268
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
ipcMain.handle(IPC_CHANNELS.WHISPER_CANCEL_DOWNLOAD, (_, model: WhisperModel) => {
|
|
274
|
+
modelDownloadManager.cancelDownload(model);
|
|
275
|
+
return { success: true };
|
|
276
|
+
});
|
|
277
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClipboardService - Auto-copy on save with native notifications
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Copy generated summary to clipboard automatically
|
|
6
|
+
* - Toast notification confirms copy
|
|
7
|
+
* - Summary truncation to <1500 chars
|
|
8
|
+
* - Include path to full report
|
|
9
|
+
* - Support both full and compact modes
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { clipboard, Notification, app, BrowserWindow } from 'electron';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { Session, FeedbackItem } from '../SessionController';
|
|
15
|
+
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// Types
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
export interface ClipboardService {
|
|
21
|
+
copy(content: string): Promise<boolean>;
|
|
22
|
+
copyWithNotification(content: string, title?: string): Promise<boolean>;
|
|
23
|
+
estimateSize(content: string): number;
|
|
24
|
+
generateClipboardSummary(session: Session, options?: SummaryOptions): string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SummaryOptions {
|
|
28
|
+
mode: 'full' | 'compact';
|
|
29
|
+
maxLength: number;
|
|
30
|
+
includeReportPath: boolean;
|
|
31
|
+
reportPath?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_SUMMARY_OPTIONS: SummaryOptions = {
|
|
35
|
+
mode: 'compact',
|
|
36
|
+
maxLength: 1500,
|
|
37
|
+
includeReportPath: true,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// =============================================================================
|
|
41
|
+
// ClipboardService Implementation
|
|
42
|
+
// =============================================================================
|
|
43
|
+
|
|
44
|
+
class ClipboardServiceImpl implements ClipboardService {
|
|
45
|
+
private lastNotificationTime = 0;
|
|
46
|
+
private readonly NOTIFICATION_DEBOUNCE_MS = 1000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Copy content to system clipboard
|
|
50
|
+
*/
|
|
51
|
+
async copy(content: string): Promise<boolean> {
|
|
52
|
+
try {
|
|
53
|
+
clipboard.writeText(content);
|
|
54
|
+
console.log(`[Clipboard] Copied ${content.length} characters`);
|
|
55
|
+
return true;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error('[Clipboard] Failed to copy:', error);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Copy content to clipboard and show native notification
|
|
64
|
+
*/
|
|
65
|
+
async copyWithNotification(content: string, title?: string): Promise<boolean> {
|
|
66
|
+
const success = await this.copy(content);
|
|
67
|
+
|
|
68
|
+
// Debounce notifications
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
if (now - this.lastNotificationTime < this.NOTIFICATION_DEBOUNCE_MS) {
|
|
71
|
+
return success;
|
|
72
|
+
}
|
|
73
|
+
this.lastNotificationTime = now;
|
|
74
|
+
|
|
75
|
+
if (success) {
|
|
76
|
+
this.showNotification(
|
|
77
|
+
title || 'markupr',
|
|
78
|
+
'Summary copied to clipboard!',
|
|
79
|
+
'Paste into your AI coding assistant.'
|
|
80
|
+
);
|
|
81
|
+
} else {
|
|
82
|
+
this.showNotification(
|
|
83
|
+
'markupr',
|
|
84
|
+
'Failed to copy',
|
|
85
|
+
'Please try again or copy manually.'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return success;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Estimate byte size for UTF-8 content
|
|
94
|
+
*/
|
|
95
|
+
estimateSize(content: string): number {
|
|
96
|
+
// Calculate UTF-8 byte size
|
|
97
|
+
return Buffer.byteLength(content, 'utf8');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Generate a clipboard-friendly summary from a session
|
|
102
|
+
* Follows llms.txt format for AI consumption
|
|
103
|
+
*/
|
|
104
|
+
generateClipboardSummary(
|
|
105
|
+
session: Session,
|
|
106
|
+
options: Partial<SummaryOptions> = {}
|
|
107
|
+
): string {
|
|
108
|
+
const opts: SummaryOptions = { ...DEFAULT_SUMMARY_OPTIONS, ...options };
|
|
109
|
+
|
|
110
|
+
const duration = session.endTime
|
|
111
|
+
? Math.round((session.endTime - session.startTime) / 1000)
|
|
112
|
+
: Math.round((Date.now() - session.startTime) / 1000);
|
|
113
|
+
|
|
114
|
+
const feedbackItems = session.feedbackItems;
|
|
115
|
+
|
|
116
|
+
// Build summary based on mode
|
|
117
|
+
let summary: string;
|
|
118
|
+
|
|
119
|
+
if (opts.mode === 'full') {
|
|
120
|
+
summary = this.generateFullSummary(feedbackItems, duration);
|
|
121
|
+
} else {
|
|
122
|
+
summary = this.generateCompactSummary(feedbackItems, duration);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Add report path if available
|
|
126
|
+
if (opts.includeReportPath && opts.reportPath) {
|
|
127
|
+
summary += `\n\n---\nFull report: ${opts.reportPath}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Truncate if needed
|
|
131
|
+
if (summary.length > opts.maxLength) {
|
|
132
|
+
summary = this.truncateSummary(summary, opts.maxLength);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return summary;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Generate full summary with all feedback items
|
|
140
|
+
*/
|
|
141
|
+
private generateFullSummary(items: FeedbackItem[], durationSec: number): string {
|
|
142
|
+
const lines: string[] = [
|
|
143
|
+
'# Feedback Session',
|
|
144
|
+
'',
|
|
145
|
+
`**Duration:** ${this.formatDuration(durationSec)}`,
|
|
146
|
+
`**Items:** ${items.length}`,
|
|
147
|
+
'',
|
|
148
|
+
'---',
|
|
149
|
+
'',
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
for (const item of items) {
|
|
153
|
+
const time = new Date(item.timestamp).toLocaleTimeString('en-US', {
|
|
154
|
+
hour: '2-digit',
|
|
155
|
+
minute: '2-digit',
|
|
156
|
+
second: '2-digit',
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
lines.push(`## [${time}]`);
|
|
160
|
+
lines.push('');
|
|
161
|
+
lines.push(item.text);
|
|
162
|
+
|
|
163
|
+
if (item.screenshot) {
|
|
164
|
+
lines.push('');
|
|
165
|
+
lines.push(`_[Screenshot captured: ${item.screenshot.width}x${item.screenshot.height}]_`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
lines.push('');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return lines.join('\n');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Generate compact summary optimized for clipboard
|
|
176
|
+
*/
|
|
177
|
+
private generateCompactSummary(items: FeedbackItem[], durationSec: number): string {
|
|
178
|
+
const lines: string[] = [
|
|
179
|
+
'# Feedback Summary',
|
|
180
|
+
'',
|
|
181
|
+
`Duration: ${this.formatDuration(durationSec)} | ${items.length} items | ${items.filter((i) => i.screenshot).length} screenshots`,
|
|
182
|
+
'',
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
// Group similar items and extract key points
|
|
186
|
+
const keyPoints = this.extractKeyPoints(items);
|
|
187
|
+
|
|
188
|
+
if (keyPoints.length > 0) {
|
|
189
|
+
lines.push('## Key Points');
|
|
190
|
+
lines.push('');
|
|
191
|
+
|
|
192
|
+
for (const point of keyPoints) {
|
|
193
|
+
lines.push(`- ${point}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
lines.push('');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Add timeline summary
|
|
200
|
+
lines.push('## Timeline');
|
|
201
|
+
lines.push('');
|
|
202
|
+
|
|
203
|
+
for (const item of items) {
|
|
204
|
+
const time = new Date(item.timestamp).toLocaleTimeString('en-US', {
|
|
205
|
+
hour: '2-digit',
|
|
206
|
+
minute: '2-digit',
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Truncate text for compact view
|
|
210
|
+
const text =
|
|
211
|
+
item.text.length > 100 ? item.text.substring(0, 97) + '...' : item.text;
|
|
212
|
+
|
|
213
|
+
const screenshotIndicator = item.screenshot ? ' [img]' : '';
|
|
214
|
+
lines.push(`- **${time}:** ${text}${screenshotIndicator}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return lines.join('\n');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Extract key points from feedback items
|
|
222
|
+
* Simple heuristics for now, could be enhanced with NLP
|
|
223
|
+
*/
|
|
224
|
+
private extractKeyPoints(items: FeedbackItem[]): string[] {
|
|
225
|
+
const keyPoints: string[] = [];
|
|
226
|
+
|
|
227
|
+
// Filter out very short or low-confidence items
|
|
228
|
+
const significantItems = items.filter(
|
|
229
|
+
(item) => item.text.length > 20 && item.confidence > 0.7
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// Extract unique key phrases
|
|
233
|
+
const seen = new Set<string>();
|
|
234
|
+
|
|
235
|
+
for (const item of significantItems) {
|
|
236
|
+
// Look for action words and important phrases
|
|
237
|
+
const text = item.text.toLowerCase();
|
|
238
|
+
|
|
239
|
+
// Skip if too similar to existing points
|
|
240
|
+
const normalized = text.substring(0, 50);
|
|
241
|
+
if (seen.has(normalized)) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
seen.add(normalized);
|
|
245
|
+
|
|
246
|
+
// Capitalize first letter and add
|
|
247
|
+
const point = item.text.charAt(0).toUpperCase() + item.text.slice(1);
|
|
248
|
+
keyPoints.push(point);
|
|
249
|
+
|
|
250
|
+
// Limit to 5 key points
|
|
251
|
+
if (keyPoints.length >= 5) {
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return keyPoints;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Truncate summary while keeping it readable
|
|
261
|
+
*/
|
|
262
|
+
private truncateSummary(summary: string, maxLength: number): string {
|
|
263
|
+
if (summary.length <= maxLength) {
|
|
264
|
+
return summary;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Find a good break point (end of line or sentence)
|
|
268
|
+
const targetLength = maxLength - 50; // Leave room for truncation notice
|
|
269
|
+
let breakPoint = summary.lastIndexOf('\n', targetLength);
|
|
270
|
+
|
|
271
|
+
if (breakPoint === -1 || breakPoint < targetLength * 0.7) {
|
|
272
|
+
// Try sentence boundary
|
|
273
|
+
breakPoint = summary.lastIndexOf('. ', targetLength);
|
|
274
|
+
|
|
275
|
+
if (breakPoint === -1 || breakPoint < targetLength * 0.5) {
|
|
276
|
+
breakPoint = targetLength;
|
|
277
|
+
} else {
|
|
278
|
+
breakPoint += 1; // Include the period
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
summary.substring(0, breakPoint) +
|
|
284
|
+
'\n\n_[Truncated - see full report for details]_'
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Format duration in human-readable form
|
|
290
|
+
*/
|
|
291
|
+
private formatDuration(seconds: number): string {
|
|
292
|
+
if (seconds < 60) {
|
|
293
|
+
return `${seconds}s`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const minutes = Math.floor(seconds / 60);
|
|
297
|
+
const remainingSeconds = seconds % 60;
|
|
298
|
+
|
|
299
|
+
if (minutes < 60) {
|
|
300
|
+
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const hours = Math.floor(minutes / 60);
|
|
304
|
+
const remainingMinutes = minutes % 60;
|
|
305
|
+
|
|
306
|
+
return `${hours}h ${remainingMinutes}m`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Show native notification
|
|
311
|
+
*/
|
|
312
|
+
private showNotification(title: string, body: string, subtitle?: string): void {
|
|
313
|
+
// Check if notifications are supported
|
|
314
|
+
if (!Notification.isSupported()) {
|
|
315
|
+
console.log(`[Notification] (unsupported) ${title}: ${body}`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const notification = new Notification({
|
|
320
|
+
title,
|
|
321
|
+
body,
|
|
322
|
+
subtitle,
|
|
323
|
+
silent: false,
|
|
324
|
+
icon: this.getIconPath(),
|
|
325
|
+
timeoutType: 'default', // Auto-dismiss
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
notification.on('click', () => {
|
|
329
|
+
// Focus the app when notification is clicked
|
|
330
|
+
const windows = BrowserWindow.getAllWindows();
|
|
331
|
+
if (windows.length > 0) {
|
|
332
|
+
windows[0].focus();
|
|
333
|
+
}
|
|
334
|
+
console.log('[Notification] Clicked - focusing app');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
notification.on('close', () => {
|
|
338
|
+
console.log('[Notification] Dismissed');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
notification.show();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Get app icon path for notification
|
|
346
|
+
*/
|
|
347
|
+
private getIconPath(): string | undefined {
|
|
348
|
+
// macOS uses the app icon automatically
|
|
349
|
+
if (process.platform === 'darwin') {
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// For Windows/Linux, try to find the icon
|
|
354
|
+
// This would be in the app resources after packaging
|
|
355
|
+
try {
|
|
356
|
+
const iconPath = path.join(app.getAppPath(), 'assets', 'icon.png');
|
|
357
|
+
return iconPath;
|
|
358
|
+
} catch {
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// =============================================================================
|
|
365
|
+
// Singleton Export
|
|
366
|
+
// =============================================================================
|
|
367
|
+
|
|
368
|
+
export const clipboardService = new ClipboardServiceImpl();
|
|
369
|
+
export default ClipboardService;
|