documentation-hub 5.7.2
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/.eslintrc.json +43 -0
- package/.github/workflows/build.yml +64 -0
- package/.github/workflows/ci.yml +39 -0
- package/.vscode/extensions.json +3 -0
- package/Current.md +97 -0
- package/DocHub_Image.png +0 -0
- package/README.md +666 -0
- package/USER_GUIDE.md +1173 -0
- package/Updater.md +311 -0
- package/build/256x256.png +0 -0
- package/build/512x512.png +0 -0
- package/build/app-update.yml +4 -0
- package/build/create-icon.js +208 -0
- package/build/icon.ico +0 -0
- package/build/icon.png +0 -0
- package/build/icon_1024x1024.png +0 -0
- package/dist/assets/Analytics-BpsG9895.js +1 -0
- package/dist/assets/Card-IAZin8kp.js +1 -0
- package/dist/assets/CurrentSession-B-rFkHvf.js +12 -0
- package/dist/assets/Dashboard-C_5gMb0q.js +1 -0
- package/dist/assets/Documents-CqZ25axS.js +1 -0
- package/dist/assets/Input-l89xwXBi.js +1 -0
- package/dist/assets/Reporting-DqdHJY_a.js +1 -0
- package/dist/assets/Search-XNbu5z_3.js +1 -0
- package/dist/assets/SessionManager-lH9hZfzH.js +1 -0
- package/dist/assets/Sessions-ClZOPYNc.js +1 -0
- package/dist/assets/Settings-DUEHGURa.js +11 -0
- package/dist/assets/index-8xUe8ptc.js +24 -0
- package/dist/assets/index-RYyJqF7O.css +1 -0
- package/dist/assets/path-BkOl0AGO.js +1 -0
- package/dist/assets/promises-ID_B9S-h.js +1 -0
- package/dist/assets/urlHelpers-TvgahX0r.js +1 -0
- package/dist/assets/useToast-yRSO1dkm.js +1 -0
- package/dist/assets/vendor-charts-RkGK5ROP.js +36 -0
- package/dist/assets/vendor-db-l0sNRNKZ.js +1 -0
- package/dist/assets/vendor-react-BVZ_anCF.js +4 -0
- package/dist/assets/vendor-search-Dw8P0qyA.js +1 -0
- package/dist/assets/vendor-ui-BU7NfluV.js +53 -0
- package/dist/electron/PowerAutomateApiService-LfW09ZGr.js +147 -0
- package/dist/electron/main-CXkNtyv-.js +19789 -0
- package/dist/electron/main.js +5 -0
- package/dist/electron/preload.js +1 -0
- package/dist/icon.png +0 -0
- package/dist/index.html +27 -0
- package/docs/CODEBASE_ANALYSIS_REPORT.md +309 -0
- package/docs/DEBUG_LOGGING_GUIDE.md +244 -0
- package/docs/README.md +115 -0
- package/docs/TOC_WIRING_GUIDE.md +344 -0
- package/docs/analysis/Bullet_Symbol_Bug_Analysis.md +136 -0
- package/docs/analysis/DOCXMLATER_ANALYSIS_SUMMARY.txt +169 -0
- package/docs/analysis/Document_Processing_Issues_Analysis.md +704 -0
- package/docs/analysis/FIELD_PRESERVATION_ANALYSIS.md +1200 -0
- package/docs/analysis/INDENTATION_PRESERVE_ANALYSIS.md +181 -0
- package/docs/analysis/INDENTATION_PRESERVE_IMPLEMENTATION.md +207 -0
- package/docs/analysis/List_Implementation.md +206 -0
- package/docs/analysis/List_Implementation_Accuracy_Report.md +366 -0
- package/docs/analysis/PROCESSING_OPTIONS_UI_UPDATES.md +220 -0
- package/docs/analysis/RefactorStyles.md +852 -0
- package/docs/analysis/STYLE_PARAMETER_ENHANCEMENT.md +143 -0
- package/docs/analysis/docxmlater-comparison-todo-2025-11-13.md +636 -0
- package/docs/analysis/docxmlater-implementation-analysis-2025-11-13.md +340 -0
- package/docs/analysis/docxmlater-template_ui-integration-analysis.md +263 -0
- package/docs/analysis/github-issues-to-create.md +237 -0
- package/docs/api/API_README.md +538 -0
- package/docs/api/API_REFERENCE.md +751 -0
- package/docs/api/TYPE_DEFINITIONS.md +869 -0
- package/docs/architecture/FONT_EMBEDDING_GUIDE.md +318 -0
- package/docs/architecture/docxmlater-functions-and-structure.md +726 -0
- package/docs/docxmlater-readme.md +1341 -0
- package/docs/fixes/EXECUTION_LOG_TEST_BASE.md +573 -0
- package/docs/fixes/HYPERLINK_TEXT_SANITIZATION.md +253 -0
- package/docs/fixes/README.md +37 -0
- package/docs/github-issues/issue-1-body.md +125 -0
- package/docs/github-issues/issue-10-body.md +850 -0
- package/docs/github-issues/issue-2-body.md +200 -0
- package/docs/github-issues/issue-3-body.md +270 -0
- package/docs/github-issues/issue-4-body.md +169 -0
- package/docs/github-issues/issue-5-body.md +173 -0
- package/docs/github-issues/issue-6-body.md +158 -0
- package/docs/github-issues/issue-7-body.md +171 -0
- package/docs/github-issues/issue-8-body.md +407 -0
- package/docs/github-issues/issue-9-body.md +515 -0
- package/docs/github-issues/issue-tracker.md +274 -0
- package/docs/github-issues/predictive-analysis-2025-10-18.md +2131 -0
- package/docs/implementation/List_Framework_Refactor_Plan.md +336 -0
- package/docs/implementation/PRIMARY_TEXT_COLOR_FEATURE.md +217 -0
- package/docs/implementation/RELEASE_PLAN_v2.1.0.md +362 -0
- package/docs/implementation/RefactorStyles.md +588 -0
- package/docs/implementation/implement-plan.md +489 -0
- package/docs/implementation/missing-helpers-implementation.md +391 -0
- package/docs/implementation/refactor-plan.md +520 -0
- package/docs/implementation/session-implementation-complete.md +233 -0
- package/docs/implementation/session-management-plan.md +250 -0
- package/docs/setup-checklist.md +77 -0
- package/docs/versions/changelog.md +345 -0
- package/electron/customUpdater.ts +656 -0
- package/electron/main.ts +2441 -0
- package/electron/memoryConfig.ts +187 -0
- package/electron/preload.ts +394 -0
- package/electron/proxyConfig.ts +340 -0
- package/electron/services/BackupService.ts +452 -0
- package/electron/services/DictionaryService.ts +402 -0
- package/electron/services/LocalDictionaryLookupService.ts +147 -0
- package/electron/services/PowerAutomateApiService.ts +231 -0
- package/electron/services/SharePointSyncService.ts +474 -0
- package/electron/windowsCertStore.ts +427 -0
- package/electron/zscalerConfig.ts +381 -0
- package/eslint.config.js +92 -0
- package/jest.config.js +52 -0
- package/package.json +214 -0
- package/postcss.config.mjs +6 -0
- package/public/icon.png +0 -0
- package/publish-release.ps1 +5 -0
- package/renovate.json +30 -0
- package/src/App.tsx +216 -0
- package/src/__mocks__/p-limit.js +12 -0
- package/src/__mocks__/styleMock.js +1 -0
- package/src/components/common/BugReportButton.tsx +44 -0
- package/src/components/common/BugReportDialog.tsx +193 -0
- package/src/components/common/Button.tsx +153 -0
- package/src/components/common/Card.tsx +86 -0
- package/src/components/common/ColorPickerDialog.tsx +177 -0
- package/src/components/common/ConfirmDialog.tsx +96 -0
- package/src/components/common/DebugConsole.tsx +275 -0
- package/src/components/common/EmptyState.tsx +183 -0
- package/src/components/common/ErrorBoundary.tsx +98 -0
- package/src/components/common/ErrorDetailsDialog.tsx +153 -0
- package/src/components/common/ErrorFallback.tsx +218 -0
- package/src/components/common/Input.tsx +109 -0
- package/src/components/common/Skeleton.tsx +184 -0
- package/src/components/common/SplashScreen.tsx +81 -0
- package/src/components/common/Toast.tsx +155 -0
- package/src/components/common/Tooltip.tsx +79 -0
- package/src/components/common/UpdateNotification.tsx +320 -0
- package/src/components/comparison/ComparisonWindow.tsx +374 -0
- package/src/components/comparison/SideBySideDiff.tsx +486 -0
- package/src/components/comparison/index.ts +8 -0
- package/src/components/document/DocumentUploader.tsx +288 -0
- package/src/components/document/HyperlinkPreview.tsx +430 -0
- package/src/components/document/HyperlinkService.md +1484 -0
- package/src/components/document/Hyperlink_Technical_Documentation.md +496 -0
- package/src/components/document/InlineChangesView.tsx +707 -0
- package/src/components/document/ProcessingProgress.tsx +303 -0
- package/src/components/document/ProcessingResults.tsx +256 -0
- package/src/components/document/TrackedChangesDetail.tsx +530 -0
- package/src/components/document/TrackedChangesPanel.tsx +546 -0
- package/src/components/document/VirtualDocumentList.tsx +240 -0
- package/src/components/editor/DocumentEditor.tsx +723 -0
- package/src/components/editor/DocumentEditorModal.tsx +640 -0
- package/src/components/editor/EditorQuickActions.tsx +502 -0
- package/src/components/editor/EditorToolbar.tsx +312 -0
- package/src/components/editor/TableEditor.tsx +926 -0
- package/src/components/editor/index.ts +18 -0
- package/src/components/layout/Header.tsx +190 -0
- package/src/components/layout/Sidebar.tsx +313 -0
- package/src/components/layout/TitleBar.tsx +190 -0
- package/src/components/navigation/CommandPalette.tsx +233 -0
- package/src/components/navigation/KeyboardShortcutsModal.tsx +173 -0
- package/src/components/sessions/ChangeItem.tsx +408 -0
- package/src/components/sessions/ChangeViewer.tsx +1155 -0
- package/src/components/sessions/DocumentComparisonModal.tsx +314 -0
- package/src/components/sessions/ProcessingOptions.tsx +297 -0
- package/src/components/sessions/ReplacementsTab.tsx +438 -0
- package/src/components/sessions/RevisionHandlingOptions.tsx +87 -0
- package/src/components/sessions/SessionManager.tsx +188 -0
- package/src/components/sessions/StylesEditor.tsx +1335 -0
- package/src/components/sessions/TabContainer.tsx +151 -0
- package/src/components/sessions/VirtualSessionList.tsx +157 -0
- package/src/components/sessions/sessionToProcessorManager.tsx +420 -0
- package/src/components/settings/CertificateManager.tsx +410 -0
- package/src/components/settings/SegmentedControl.tsx +88 -0
- package/src/components/settings/SettingRow.tsx +52 -0
- package/src/contexts/GlobalStatsContext.tsx +396 -0
- package/src/contexts/SessionContext.tsx +2129 -0
- package/src/contexts/ThemeContext.tsx +428 -0
- package/src/contexts/UserSettingsContext.tsx +290 -0
- package/src/contexts/__tests__/GlobalStatsContext.test.tsx +390 -0
- package/src/global.d.ts +273 -0
- package/src/hooks/useDocumentQueue.tsx +210 -0
- package/src/hooks/useToast.tsx +55 -0
- package/src/main.tsx +10 -0
- package/src/pages/Analytics.tsx +386 -0
- package/src/pages/CurrentSession.tsx +1174 -0
- package/src/pages/Dashboard.tsx +319 -0
- package/src/pages/Documents.tsx +317 -0
- package/src/pages/Projects.tsx +250 -0
- package/src/pages/Reporting.tsx +386 -0
- package/src/pages/Search.tsx +349 -0
- package/src/pages/Sessions.tsx +285 -0
- package/src/pages/Settings.tsx +2662 -0
- package/src/services/HyperlinkService.ts +1085 -0
- package/src/services/document/DocXMLaterProcessor.ts +617 -0
- package/src/services/document/DocumentProcessingComparison.ts +856 -0
- package/src/services/document/DocumentSnapshotService.ts +575 -0
- package/src/services/document/WordDocumentProcessor.ts +10509 -0
- package/src/services/document/__tests__/DocXMLaterProcessor.hyperlinks.test.md +311 -0
- package/src/services/document/__tests__/WordDocumentProcessor.integration.test.ts +515 -0
- package/src/services/document/__tests__/WordDocumentProcessor.test.ts +812 -0
- package/src/services/document/blanklines/BlankLineManager.ts +658 -0
- package/src/services/document/blanklines/__tests__/paragraphChecks.test.ts +281 -0
- package/src/services/document/blanklines/helpers/blankLineInsertion.ts +87 -0
- package/src/services/document/blanklines/helpers/blankLineSnapshot.ts +251 -0
- package/src/services/document/blanklines/helpers/clearCustom.ts +121 -0
- package/src/services/document/blanklines/helpers/contextChecks.ts +117 -0
- package/src/services/document/blanklines/helpers/imageChecks.ts +51 -0
- package/src/services/document/blanklines/helpers/paragraphChecks.ts +236 -0
- package/src/services/document/blanklines/helpers/removeBlanksBetweenListItems.ts +91 -0
- package/src/services/document/blanklines/helpers/removeTrailingBlanks.ts +35 -0
- package/src/services/document/blanklines/helpers/tableGuards.ts +21 -0
- package/src/services/document/blanklines/index.ts +67 -0
- package/src/services/document/blanklines/rules/additionRules.ts +337 -0
- package/src/services/document/blanklines/rules/indentationRules.ts +317 -0
- package/src/services/document/blanklines/rules/removalRules.ts +362 -0
- package/src/services/document/blanklines/rules/ruleTypes.ts +92 -0
- package/src/services/document/blanklines/types.ts +29 -0
- package/src/services/document/helpers/ImageBorderCropper.ts +377 -0
- package/src/services/document/helpers/__tests__/whitespace.test.ts +272 -0
- package/src/services/document/helpers/whitespace.ts +117 -0
- package/src/services/document/list/ListNormalizer.ts +947 -0
- package/src/services/document/list/index.ts +45 -0
- package/src/services/document/list/list-detection.ts +275 -0
- package/src/services/document/list/list-types.ts +162 -0
- package/src/services/document/processors/HyperlinkProcessor.ts +370 -0
- package/src/services/document/processors/ListProcessor.ts +257 -0
- package/src/services/document/processors/StructureProcessor.ts +176 -0
- package/src/services/document/processors/StyleProcessor.ts +389 -0
- package/src/services/document/processors/TableProcessor.ts +2238 -0
- package/src/services/document/processors/__tests__/HyperlinkProcessor.test.ts +314 -0
- package/src/services/document/processors/__tests__/ListProcessor.test.ts +291 -0
- package/src/services/document/processors/__tests__/StructureProcessor.test.ts +257 -0
- package/src/services/document/processors/__tests__/TableProcessor.hlp-tips-bullets.test.ts +459 -0
- package/src/services/document/processors/__tests__/TableProcessor.test.ts +1604 -0
- package/src/services/document/processors/index.ts +28 -0
- package/src/services/document/types/docx-processing.ts +310 -0
- package/src/services/editor/EditorActionHandlers.ts +901 -0
- package/src/services/editor/index.ts +13 -0
- package/src/setupTests.ts +47 -0
- package/src/styles/global.css +782 -0
- package/src/types/backup.ts +132 -0
- package/src/types/dictionary.ts +125 -0
- package/src/types/document-processing.ts +331 -0
- package/src/types/docxmlater-augments.d.ts +142 -0
- package/src/types/editor.ts +280 -0
- package/src/types/electron.ts +340 -0
- package/src/types/globalStats.ts +155 -0
- package/src/types/hyperlink.ts +471 -0
- package/src/types/operations.ts +354 -0
- package/src/types/session.ts +427 -0
- package/src/types/settings.ts +112 -0
- package/src/utils/MemoryMonitor.ts +248 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/colorConvert.ts +306 -0
- package/src/utils/diffUtils.ts +347 -0
- package/src/utils/documentUtils.ts +202 -0
- package/src/utils/electronGuard.ts +62 -0
- package/src/utils/indexedDB.ts +915 -0
- package/src/utils/logger.ts +717 -0
- package/src/utils/pathSecurity.ts +232 -0
- package/src/utils/pathValidator.ts +236 -0
- package/src/utils/processingTimeEstimator.ts +153 -0
- package/src/utils/safeJsonParse.ts +62 -0
- package/src/utils/textSanitizer.ts +162 -0
- package/src/utils/urlHelpers.ts +304 -0
- package/src/utils/urlPatterns.ts +198 -0
- package/src/utils/urlSanitizer.ts +152 -0
- package/src/vite-env.d.ts +11 -0
- package/tsconfig.electron.json +19 -0
- package/tsconfig.json +36 -0
- package/tsconfig.node.json +12 -0
- package/typedoc.json +45 -0
- package/vite.config.ts +152 -0
package/electron/main.ts
ADDED
|
@@ -0,0 +1,2441 @@
|
|
|
1
|
+
import { app, BrowserWindow, dialog, ipcMain, Menu, net, screen, session, shell } from "electron";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import { promises as fsPromises } from "fs";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { WordDocumentProcessor } from "../src/services/document/WordDocumentProcessor";
|
|
7
|
+
import type { SharePointConfig } from "../src/types/dictionary";
|
|
8
|
+
import type { BatchProcessingResult, HyperlinkProcessingResult } from "../src/types/hyperlink";
|
|
9
|
+
import { initializeLogging, logger } from "../src/utils/logger";
|
|
10
|
+
import { CustomUpdater } from "./customUpdater";
|
|
11
|
+
import { MemoryConfig } from "./memoryConfig";
|
|
12
|
+
import type { BackupConfig } from "./services/BackupService";
|
|
13
|
+
import { BackupService } from "./services/BackupService";
|
|
14
|
+
import { getDictionaryService } from "./services/DictionaryService";
|
|
15
|
+
import { getLocalDictionaryLookupService } from "./services/LocalDictionaryLookupService";
|
|
16
|
+
import { getSharePointSyncService } from "./services/SharePointSyncService";
|
|
17
|
+
|
|
18
|
+
let mainWindow: BrowserWindow | null = null;
|
|
19
|
+
const isDev = !app.isPackaged;
|
|
20
|
+
|
|
21
|
+
// Create namespaced logger for main process
|
|
22
|
+
const log = logger.namespace("Main");
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Initialize Logging System (MUST be first)
|
|
26
|
+
// ============================================================================
|
|
27
|
+
initializeLogging();
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Application Startup and Version Information
|
|
31
|
+
// ============================================================================
|
|
32
|
+
log.info("========================================");
|
|
33
|
+
log.info(`Documentation Hub v${app.getVersion()} starting...`);
|
|
34
|
+
log.info(`Electron: v${process.versions.electron}`);
|
|
35
|
+
log.info(`Node: v${process.versions.node}`);
|
|
36
|
+
log.info(`Platform: ${process.platform} ${process.arch}`);
|
|
37
|
+
log.info("========================================");
|
|
38
|
+
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// Shared IPC Path Validation
|
|
41
|
+
// ============================================================================
|
|
42
|
+
/**
|
|
43
|
+
* Validates a file path received via IPC to prevent path traversal attacks.
|
|
44
|
+
* Options allow requiring specific extensions or file existence.
|
|
45
|
+
*/
|
|
46
|
+
function validateIpcPath(
|
|
47
|
+
filePath: string,
|
|
48
|
+
options: { requireExists?: boolean; allowedExtensions?: string[]; mustBeFile?: boolean } = {}
|
|
49
|
+
): string {
|
|
50
|
+
if (!filePath || typeof filePath !== "string") {
|
|
51
|
+
throw new Error("No file path provided");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// SECURITY: Check for path traversal attempts before normalization
|
|
55
|
+
if (filePath.includes("..")) {
|
|
56
|
+
throw new Error('Path traversal detected - relative paths with ".." are not allowed');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Normalize path
|
|
60
|
+
const normalizedPath = path.resolve(filePath);
|
|
61
|
+
|
|
62
|
+
// SECURITY: Double-check after normalization (defense in depth)
|
|
63
|
+
if (normalizedPath.includes("..")) {
|
|
64
|
+
throw new Error("Path traversal detected after normalization");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Validate extension if required
|
|
68
|
+
if (options.allowedExtensions && options.allowedExtensions.length > 0) {
|
|
69
|
+
const ext = path.extname(normalizedPath).toLowerCase();
|
|
70
|
+
if (!options.allowedExtensions.includes(ext)) {
|
|
71
|
+
throw new Error(`Unsupported file type: ${ext}. Allowed: ${options.allowedExtensions.join(", ")}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Validate existence if required
|
|
76
|
+
if (options.requireExists) {
|
|
77
|
+
if (!fs.existsSync(normalizedPath)) {
|
|
78
|
+
throw new Error(`File not found: ${normalizedPath}`);
|
|
79
|
+
}
|
|
80
|
+
if (options.mustBeFile) {
|
|
81
|
+
const stats = fs.statSync(normalizedPath);
|
|
82
|
+
if (!stats.isFile()) {
|
|
83
|
+
throw new Error("Path is not a file");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return normalizedPath;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ============================================================================
|
|
92
|
+
// Shared API URL Validation (SSRF Prevention)
|
|
93
|
+
// ============================================================================
|
|
94
|
+
/**
|
|
95
|
+
* Validates an API URL to prevent SSRF attacks. Only allows HTTPS requests
|
|
96
|
+
* to known Power Automate / Azure Logic Apps domains.
|
|
97
|
+
*/
|
|
98
|
+
function validateApiUrl(url: string): URL {
|
|
99
|
+
if (!url || typeof url !== "string") {
|
|
100
|
+
throw new Error("No API URL provided");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let parsed: URL;
|
|
104
|
+
try {
|
|
105
|
+
parsed = new URL(url);
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Error("Invalid API URL");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (parsed.protocol !== "https:") {
|
|
111
|
+
throw new Error("API URL must use HTTPS");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Only allow Power Automate / Logic Apps endpoints
|
|
115
|
+
const allowedDomains = [
|
|
116
|
+
"logic.azure.com",
|
|
117
|
+
"prod-00.logic.azure.com",
|
|
118
|
+
"azure-api.net",
|
|
119
|
+
"azure.com",
|
|
120
|
+
"powerplatform.com",
|
|
121
|
+
"api.powerplatform.com",
|
|
122
|
+
"flow.microsoft.com",
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
126
|
+
const isAllowed = allowedDomains.some(
|
|
127
|
+
(domain) => hostname === domain || hostname.endsWith("." + domain)
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (!isAllowed) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`API URL domain "${hostname}" is not in the allowed list. ` +
|
|
133
|
+
`Only Power Automate / Azure Logic Apps endpoints are permitted.`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Block private/internal IP ranges
|
|
138
|
+
const ipv4Match = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
139
|
+
if (ipv4Match) {
|
|
140
|
+
throw new Error("API URL must not point to an IP address");
|
|
141
|
+
}
|
|
142
|
+
if (hostname === "localhost" || hostname === "[::1]" || hostname.endsWith(".local")) {
|
|
143
|
+
throw new Error("API URL must not point to a local address");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return parsed;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ============================================================================
|
|
150
|
+
// Memory Configuration (MUST be before app.ready)
|
|
151
|
+
// ============================================================================
|
|
152
|
+
log.info("Configuring memory and heap size...");
|
|
153
|
+
MemoryConfig.configureApp();
|
|
154
|
+
|
|
155
|
+
// ============================================================================
|
|
156
|
+
// Single Instance Lock - Prevent Multiple App Instances (Production Only)
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// Skip in development - vite-plugin-electron needs to restart Electron on changes
|
|
159
|
+
if (!isDev) {
|
|
160
|
+
const gotTheLock = app.requestSingleInstanceLock();
|
|
161
|
+
|
|
162
|
+
if (!gotTheLock) {
|
|
163
|
+
log.info("Another instance is already running. Quitting this instance...");
|
|
164
|
+
app.quit();
|
|
165
|
+
process.exit(0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
app.on("second-instance", (_event, _commandLine, _workingDirectory) => {
|
|
169
|
+
log.info("Second instance detected - focusing existing window");
|
|
170
|
+
if (mainWindow) {
|
|
171
|
+
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
172
|
+
mainWindow.focus();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ============================================================================
|
|
178
|
+
// Session Configuration
|
|
179
|
+
// ============================================================================
|
|
180
|
+
async function configureSession(): Promise<void> {
|
|
181
|
+
log.info("Configuring session...");
|
|
182
|
+
try {
|
|
183
|
+
// Set User-Agent
|
|
184
|
+
const userAgent = `DocumentationHub/${app.getVersion()} (${process.platform})`;
|
|
185
|
+
session.defaultSession.setUserAgent(userAgent);
|
|
186
|
+
|
|
187
|
+
log.info("✓ Session configured successfully");
|
|
188
|
+
} catch (error) {
|
|
189
|
+
log.error("❌ Failed to configure session:", error);
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
// ============================================================================
|
|
196
|
+
// CRITICAL SECURITY CONFIGURATION - DO NOT MODIFY
|
|
197
|
+
// ============================================================================
|
|
198
|
+
/**
|
|
199
|
+
* These Electron security settings are MANDATORY and must never be changed.
|
|
200
|
+
*
|
|
201
|
+
* WHY THESE SETTINGS MATTER:
|
|
202
|
+
*
|
|
203
|
+
* 1. nodeIntegration: false (REQUIRED)
|
|
204
|
+
* - Prevents renderer process from accessing Node.js APIs
|
|
205
|
+
* - Critical security protection against XSS attacks
|
|
206
|
+
* - If enabled: malicious websites/content can access your filesystem, spawn processes, etc.
|
|
207
|
+
*
|
|
208
|
+
* 2. contextIsolation: true (REQUIRED)
|
|
209
|
+
* - Isolates preload script context from web page context
|
|
210
|
+
* - Required for React to work properly (lazy loading, Context API, Router)
|
|
211
|
+
* - Enables secure IPC communication via contextBridge
|
|
212
|
+
* - If disabled: Causes BLACK SCREEN in production builds
|
|
213
|
+
*
|
|
214
|
+
* WHAT BREAKS WHEN CHANGED:
|
|
215
|
+
* - contextIsolation set to false → Black screen, React won't load
|
|
216
|
+
* - nodeIntegration enabled (true) → Security vulnerability + preload API breaks
|
|
217
|
+
*
|
|
218
|
+
* HISTORICAL INCIDENTS:
|
|
219
|
+
* - 2025-10-17: Commit 159f47b - Restored after accidental change caused black screen
|
|
220
|
+
* - 2025-10-16: Commit 290ee59 - Fixed TypeScript errors causing black screen
|
|
221
|
+
* - 2024-12-xx: Commit 7575ba6 - Fixed production build black screen
|
|
222
|
+
*
|
|
223
|
+
* This configuration is protected by:
|
|
224
|
+
* - TypeScript const assertion (compile-time)
|
|
225
|
+
* - Runtime validation in development mode
|
|
226
|
+
* - Git pre-commit hooks
|
|
227
|
+
* - CI/CD validation checks
|
|
228
|
+
*
|
|
229
|
+
* If you need to expose new APIs to the renderer:
|
|
230
|
+
* → Add them to electron/preload.ts via contextBridge.exposeInMainWorld()
|
|
231
|
+
* → NEVER enable nodeIntegration or disable contextIsolation
|
|
232
|
+
*
|
|
233
|
+
* @see https://www.electronjs.org/docs/latest/tutorial/security
|
|
234
|
+
* @see https://www.electronjs.org/docs/latest/tutorial/context-isolation
|
|
235
|
+
*/
|
|
236
|
+
const REQUIRED_SECURITY_SETTINGS = {
|
|
237
|
+
preload: join(__dirname, "preload.js"),
|
|
238
|
+
nodeIntegration: false, // MUST be false for security
|
|
239
|
+
contextIsolation: true, // MUST be true for React to work
|
|
240
|
+
} as const;
|
|
241
|
+
|
|
242
|
+
async function createWindow() {
|
|
243
|
+
mainWindow = new BrowserWindow({
|
|
244
|
+
width: 1400,
|
|
245
|
+
height: 900,
|
|
246
|
+
minWidth: 800,
|
|
247
|
+
minHeight: 600,
|
|
248
|
+
frame: false,
|
|
249
|
+
titleBarStyle: "hiddenInset",
|
|
250
|
+
backgroundColor: "#0a0a0a",
|
|
251
|
+
show: false, // ISSUE #6 FIX: Don't show window immediately - prevents black screen
|
|
252
|
+
icon: app.isPackaged
|
|
253
|
+
? join(process.resourcesPath, "icon.ico")
|
|
254
|
+
: join(__dirname, "../build/icon.ico"),
|
|
255
|
+
webPreferences: REQUIRED_SECURITY_SETTINGS,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
Menu.setApplicationMenu(null);
|
|
259
|
+
|
|
260
|
+
if (isDev) {
|
|
261
|
+
mainWindow.loadURL("http://localhost:5173");
|
|
262
|
+
mainWindow.webContents.openDevTools();
|
|
263
|
+
} else {
|
|
264
|
+
mainWindow.loadFile(join(__dirname, "../index.html"));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
268
|
+
shell.openExternal(url);
|
|
269
|
+
return { action: "deny" };
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ============================================================================
|
|
273
|
+
// ISSUE #6 FIX: ready-to-show Event
|
|
274
|
+
// ============================================================================
|
|
275
|
+
// Wait for React to load before showing window - prevents black screen flicker
|
|
276
|
+
// This ensures the renderer process has loaded and painted the UI before
|
|
277
|
+
// the window becomes visible to the user
|
|
278
|
+
mainWindow.once("ready-to-show", () => {
|
|
279
|
+
log.info("✓ Window ready to show - React loaded and rendered");
|
|
280
|
+
mainWindow?.show();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
mainWindow.on("closed", () => {
|
|
284
|
+
mainWindow = null;
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
mainWindow.on("maximize", () => {
|
|
288
|
+
mainWindow?.webContents.send("window-maximized");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
mainWindow.on("unmaximize", () => {
|
|
292
|
+
mainWindow?.webContents.send("window-unmaximized");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
mainWindow.on("enter-full-screen", () => {
|
|
296
|
+
mainWindow?.webContents.send("window-fullscreen");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
mainWindow.on("leave-full-screen", () => {
|
|
300
|
+
mainWindow?.webContents.send("window-unfullscreen");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ============================================================================
|
|
304
|
+
// Runtime Security Validation (Development Only)
|
|
305
|
+
// ============================================================================
|
|
306
|
+
if (isDev) {
|
|
307
|
+
// Validate security settings at runtime to catch accidental changes
|
|
308
|
+
// Use getPreloadScripts() - getPreloads() is deprecated as of Electron 38.x
|
|
309
|
+
const preloadScripts = mainWindow.webContents.session.getPreloadScripts();
|
|
310
|
+
|
|
311
|
+
// Getting webPreferences - this method doesn't exist on webContents
|
|
312
|
+
// We need to check the actual settings we passed during BrowserWindow creation
|
|
313
|
+
// The validation approach needs to be different
|
|
314
|
+
|
|
315
|
+
// Since we can't get webPreferences directly in newer Electron,
|
|
316
|
+
// we validate by checking if the settings we defined are still intact
|
|
317
|
+
const expectedSettings = REQUIRED_SECURITY_SETTINGS;
|
|
318
|
+
|
|
319
|
+
// We can verify our settings are applied by testing actual behavior
|
|
320
|
+
// For example, trying to access Node APIs from renderer would fail with proper settings
|
|
321
|
+
|
|
322
|
+
// For now, we'll validate our constant hasn't been modified
|
|
323
|
+
// This is a compile-time check that TypeScript enforces
|
|
324
|
+
if (expectedSettings.nodeIntegration !== false) {
|
|
325
|
+
const errorMsg = `
|
|
326
|
+
╔════════════════════════════════════════════════════════════════════════════╗
|
|
327
|
+
║ 🚨 SECURITY VIOLATION DETECTED 🚨 ║
|
|
328
|
+
╠════════════════════════════════════════════════════════════════════════════╣
|
|
329
|
+
║ ║
|
|
330
|
+
║ nodeIntegration is enabled! This is a CRITICAL security vulnerability. ║
|
|
331
|
+
║ ║
|
|
332
|
+
║ Current value: ${expectedSettings.nodeIntegration} ║
|
|
333
|
+
║ Required value: false ║
|
|
334
|
+
║ ║
|
|
335
|
+
║ This setting MUST be 'false' to: ║
|
|
336
|
+
║ - Prevent XSS attacks from accessing Node.js APIs ║
|
|
337
|
+
║ - Protect filesystem and system resources ║
|
|
338
|
+
║ - Maintain secure IPC communication ║
|
|
339
|
+
║ ║
|
|
340
|
+
║ Fix: Set nodeIntegration: false in REQUIRED_SECURITY_SETTINGS ║
|
|
341
|
+
║ Location: electron/main.ts line 352-356 ║
|
|
342
|
+
║ ║
|
|
343
|
+
╚════════════════════════════════════════════════════════════════════════════╝
|
|
344
|
+
`;
|
|
345
|
+
log.error(errorMsg);
|
|
346
|
+
throw new Error("SECURITY VIOLATION: nodeIntegration must be false");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Validate contextIsolation
|
|
350
|
+
if (expectedSettings.contextIsolation !== true) {
|
|
351
|
+
const errorMsg = `
|
|
352
|
+
╔════════════════════════════════════════════════════════════════════════════╗
|
|
353
|
+
║ 🚨 CONFIGURATION ERROR DETECTED 🚨 ║
|
|
354
|
+
╠════════════════════════════════════════════════════════════════════════════╣
|
|
355
|
+
║ ║
|
|
356
|
+
║ contextIsolation is disabled! This will cause a BLACK SCREEN. ║
|
|
357
|
+
║ ║
|
|
358
|
+
║ Current value: ${expectedSettings.contextIsolation} ║
|
|
359
|
+
║ Required value: true ║
|
|
360
|
+
║ ║
|
|
361
|
+
║ This setting MUST be 'true' for: ║
|
|
362
|
+
║ - React to render properly (lazy loading, Context API) ║
|
|
363
|
+
║ - Router navigation to work ║
|
|
364
|
+
║ - Secure preload script execution ║
|
|
365
|
+
║ - Dynamic imports to load ║
|
|
366
|
+
║ ║
|
|
367
|
+
║ Fix: Set contextIsolation: true in REQUIRED_SECURITY_SETTINGS ║
|
|
368
|
+
║ Location: electron/main.ts line 352-356 ║
|
|
369
|
+
║ ║
|
|
370
|
+
║ Historical incidents: 159f47b, 290ee59, 7575ba6 ║
|
|
371
|
+
║ ║
|
|
372
|
+
╚════════════════════════════════════════════════════════════════════════════╝
|
|
373
|
+
`;
|
|
374
|
+
log.error(errorMsg);
|
|
375
|
+
throw new Error("CONFIGURATION ERROR: contextIsolation must be true (causes black screen)");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
log.info("✅ Security validation passed - All settings correct");
|
|
379
|
+
log.info(" - nodeIntegration: false ✓");
|
|
380
|
+
log.info(" - contextIsolation: true ✓");
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Enhanced network debugging - log all network events
|
|
385
|
+
if (!isDev) {
|
|
386
|
+
app.commandLine.appendSwitch("enable-logging", "stderr");
|
|
387
|
+
app.commandLine.appendSwitch("v", "1");
|
|
388
|
+
app.commandLine.appendSwitch("vmodule", "network_delegate=1");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Handle certificate errors globally
|
|
392
|
+
// SECURITY: Only trust certificates from corporate proxy/Zscaler interceptors.
|
|
393
|
+
// We do NOT blindly trust all certificates for known domains — that would disable
|
|
394
|
+
// TLS verification and allow MITM attacks. Instead, we only allow known proxy-related
|
|
395
|
+
// certificate errors (self-signed root CA from corporate proxy) for specific hosts.
|
|
396
|
+
app.on("certificate-error", (event, webContents, url, error, certificate, callback) => {
|
|
397
|
+
log.warn("[Certificate Error]", { url, error: String(error) });
|
|
398
|
+
|
|
399
|
+
// Only consider overriding for specific, known corporate proxy certificate errors
|
|
400
|
+
// "net::ERR_CERT_AUTHORITY_INVALID" is the typical Zscaler/corporate proxy error
|
|
401
|
+
const proxyRelatedErrors = [
|
|
402
|
+
"net::ERR_CERT_AUTHORITY_INVALID",
|
|
403
|
+
];
|
|
404
|
+
|
|
405
|
+
if (!proxyRelatedErrors.includes(String(error))) {
|
|
406
|
+
// For all other certificate errors (expired, wrong host, revoked, etc.),
|
|
407
|
+
// reject immediately — these indicate real problems, not proxy interception
|
|
408
|
+
log.warn(`[Certificate Error] Rejecting: ${error} is not a proxy-related error`);
|
|
409
|
+
callback(false);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Only allow override for known Microsoft/GitHub service hosts
|
|
414
|
+
const trustedHosts = [
|
|
415
|
+
"github.com",
|
|
416
|
+
"githubusercontent.com",
|
|
417
|
+
"github.io",
|
|
418
|
+
"logic.azure.com",
|
|
419
|
+
"azure.com",
|
|
420
|
+
"microsoft.com",
|
|
421
|
+
"microsoftonline.com",
|
|
422
|
+
"windows.net",
|
|
423
|
+
"azure-api.net",
|
|
424
|
+
"azureedge.net",
|
|
425
|
+
"powerplatform.com",
|
|
426
|
+
"api.powerplatform.com",
|
|
427
|
+
];
|
|
428
|
+
|
|
429
|
+
event.preventDefault();
|
|
430
|
+
const urlHost = new URL(url).hostname.toLowerCase();
|
|
431
|
+
|
|
432
|
+
if (trustedHosts.some((host) => urlHost === host || urlHost.endsWith('.' + host))) {
|
|
433
|
+
log.info(`[Certificate Error] Allowing proxy CA override for known host: ${urlHost}`);
|
|
434
|
+
callback(true);
|
|
435
|
+
} else {
|
|
436
|
+
log.warn(`[Certificate Error] Rejecting certificate for unknown host: ${urlHost}`);
|
|
437
|
+
callback(false);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// ============================================================================
|
|
442
|
+
// CONSOLIDATED APP INITIALIZATION
|
|
443
|
+
// ============================================================================
|
|
444
|
+
app.whenReady().then(async () => {
|
|
445
|
+
const startTime = Date.now();
|
|
446
|
+
log.info("🚀 App ready - beginning initialization...");
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
// ========================================================================
|
|
450
|
+
// STEP 1: Configure Session
|
|
451
|
+
// ========================================================================
|
|
452
|
+
log.info("[1/3] Configuring session...");
|
|
453
|
+
await configureSession();
|
|
454
|
+
log.info(`✓ Step 1 complete (${Date.now() - startTime}ms)`);
|
|
455
|
+
|
|
456
|
+
// ========================================================================
|
|
457
|
+
// STEP 2: Create Main Window
|
|
458
|
+
// ========================================================================
|
|
459
|
+
log.info("[2/3] Creating main window...");
|
|
460
|
+
const windowStartTime = Date.now();
|
|
461
|
+
await createWindow();
|
|
462
|
+
log.info(`✓ Step 2 complete - Window created (${Date.now() - windowStartTime}ms)`);
|
|
463
|
+
|
|
464
|
+
// Verify mainWindow exists before proceeding
|
|
465
|
+
if (!mainWindow) {
|
|
466
|
+
throw new Error("CRITICAL: mainWindow is null after createWindow()");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ========================================================================
|
|
470
|
+
// STEP 3: Initialize Auto-Updater
|
|
471
|
+
// ========================================================================
|
|
472
|
+
log.info("[3/3] Initializing auto-updater...");
|
|
473
|
+
const updaterStartTime = Date.now();
|
|
474
|
+
|
|
475
|
+
// Initialize updater (mainWindow is guaranteed to exist now)
|
|
476
|
+
// FORCE_DEV_UPDATE_CONFIG=true allows testing updates in dev mode
|
|
477
|
+
const forceDevUpdates = process.env.FORCE_DEV_UPDATE_CONFIG === 'true';
|
|
478
|
+
if (!isDev || forceDevUpdates) {
|
|
479
|
+
updaterHandler = new AutoUpdaterHandler();
|
|
480
|
+
updaterHandler.checkOnStartup();
|
|
481
|
+
updaterHandler.startScheduledChecks(); // Check for updates every 4 hours
|
|
482
|
+
log.info(`✓ Step 3 complete - Auto-updater initialized (${Date.now() - updaterStartTime}ms)`);
|
|
483
|
+
} else {
|
|
484
|
+
log.info("⊘ Step 3 skipped - Auto-updater disabled in development mode");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ========================================================================
|
|
488
|
+
// Initialization Complete
|
|
489
|
+
// ========================================================================
|
|
490
|
+
const totalTime = Date.now() - startTime;
|
|
491
|
+
log.info(`✅ Initialization complete in ${totalTime}ms`);
|
|
492
|
+
log.info(" 1. Session configured");
|
|
493
|
+
log.info(" 2. Main window created");
|
|
494
|
+
log.info(" 3. Auto-updater initialized");
|
|
495
|
+
} catch (error) {
|
|
496
|
+
log.error("❌ CRITICAL: App initialization failed:", error);
|
|
497
|
+
log.error("Stack trace:", error instanceof Error ? error.stack : "No stack trace");
|
|
498
|
+
|
|
499
|
+
// Show error dialog to user
|
|
500
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
501
|
+
dialog.showErrorBox(
|
|
502
|
+
"Initialization Error",
|
|
503
|
+
`Documentation Hub failed to initialize properly:\n\n${error instanceof Error ? error.message : String(error)}\n\nThe app may not function correctly. Please restart the application.`
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Don't quit - allow user to try to use the app anyway
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
app.on("window-all-closed", () => {
|
|
512
|
+
if (process.platform !== "darwin") {
|
|
513
|
+
app.quit();
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
app.on("activate", () => {
|
|
518
|
+
if (mainWindow === null) {
|
|
519
|
+
createWindow();
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
ipcMain.handle("window-minimize", () => {
|
|
524
|
+
mainWindow?.minimize();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
ipcMain.handle("window-maximize", () => {
|
|
528
|
+
if (mainWindow?.isMaximized()) {
|
|
529
|
+
mainWindow.unmaximize();
|
|
530
|
+
} else {
|
|
531
|
+
mainWindow?.maximize();
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
ipcMain.handle("window-close", () => {
|
|
536
|
+
mainWindow?.close();
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
ipcMain.handle("window-is-maximized", () => {
|
|
540
|
+
return mainWindow?.isMaximized();
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
ipcMain.handle("window-is-fullscreen", () => {
|
|
544
|
+
return mainWindow?.isFullScreen();
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
ipcMain.handle("window-set-always-on-top", (_event, flag: boolean) => {
|
|
548
|
+
mainWindow?.setAlwaysOnTop(flag, 'floating');
|
|
549
|
+
mainWindow?.webContents.send('window-always-on-top-changed', flag);
|
|
550
|
+
return flag;
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
ipcMain.handle("window-is-always-on-top", () => {
|
|
554
|
+
return mainWindow?.isAlwaysOnTop();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
ipcMain.handle("app-version", () => {
|
|
558
|
+
return app.getVersion();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// Also register as 'get-app-version' for backward compatibility
|
|
562
|
+
// (previously only registered in AutoUpdaterHandler which loaded late)
|
|
563
|
+
ipcMain.handle("get-app-version", () => {
|
|
564
|
+
return app.getVersion();
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
ipcMain.handle("open-dev-tools", () => {
|
|
568
|
+
if (mainWindow) {
|
|
569
|
+
mainWindow.webContents.openDevTools();
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// Open comparison window for document processing changes
|
|
574
|
+
ipcMain.handle("open-comparison-window", async (event, data) => {
|
|
575
|
+
const { sessionId, documentId, comparisonData } = data;
|
|
576
|
+
|
|
577
|
+
// Create new window for comparison
|
|
578
|
+
const comparisonWindow = new BrowserWindow({
|
|
579
|
+
width: 1200,
|
|
580
|
+
height: 800,
|
|
581
|
+
minWidth: 900,
|
|
582
|
+
minHeight: 600,
|
|
583
|
+
title: "Document Processing Comparison",
|
|
584
|
+
webPreferences: REQUIRED_SECURITY_SETTINGS,
|
|
585
|
+
parent: mainWindow || undefined,
|
|
586
|
+
modal: false,
|
|
587
|
+
show: false,
|
|
588
|
+
backgroundColor: "#ffffff",
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// Generate HTML content from comparison data
|
|
592
|
+
// NOTE: Dynamic import here is intentional for lazy-loading (used only when opening comparison windows)
|
|
593
|
+
// Rollup warning about "dynamically imported by main.ts but statically imported by WordDocumentProcessor.ts"
|
|
594
|
+
// is expected and acceptable - see docs/architecture/bundling-strategy.md
|
|
595
|
+
const { documentProcessingComparison } =
|
|
596
|
+
await import("../src/services/document/DocumentProcessingComparison");
|
|
597
|
+
const htmlContent = documentProcessingComparison.generateHTMLReport(comparisonData);
|
|
598
|
+
|
|
599
|
+
// Load the HTML directly
|
|
600
|
+
comparisonWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`);
|
|
601
|
+
|
|
602
|
+
// Show when ready
|
|
603
|
+
comparisonWindow.once("ready-to-show", () => {
|
|
604
|
+
comparisonWindow.show();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// Cleanup on close
|
|
608
|
+
comparisonWindow.on("closed", () => {
|
|
609
|
+
// Window cleanup handled automatically
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
return { success: true };
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// Hyperlink processing IPC handlers with security validation
|
|
616
|
+
class HyperlinkIPCHandler {
|
|
617
|
+
private processor: WordDocumentProcessor;
|
|
618
|
+
private processingQueue: Map<string, AbortController> = new Map();
|
|
619
|
+
private readonly ALLOWED_BASE_PATH: string;
|
|
620
|
+
|
|
621
|
+
constructor() {
|
|
622
|
+
this.processor = new WordDocumentProcessor();
|
|
623
|
+
this.ALLOWED_BASE_PATH = app.getPath("documents");
|
|
624
|
+
this.setupHandlers();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private setupHandlers(): void {
|
|
628
|
+
// Single document processing
|
|
629
|
+
ipcMain.handle("hyperlink:process-document", async (event, request) => {
|
|
630
|
+
try {
|
|
631
|
+
// Validate file path
|
|
632
|
+
const safePath = await this.validateFilePath(request.filePath);
|
|
633
|
+
|
|
634
|
+
// Process document with timeout
|
|
635
|
+
const controller = new AbortController();
|
|
636
|
+
this.processingQueue.set(safePath, controller);
|
|
637
|
+
|
|
638
|
+
const result = await this.processWithTimeout(
|
|
639
|
+
this.processor.processDocument(safePath, request.options),
|
|
640
|
+
controller.signal,
|
|
641
|
+
60000 // 60 second timeout
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
this.processingQueue.delete(safePath);
|
|
645
|
+
return result;
|
|
646
|
+
} catch (error) {
|
|
647
|
+
return {
|
|
648
|
+
success: false,
|
|
649
|
+
totalHyperlinks: 0,
|
|
650
|
+
processedHyperlinks: 0,
|
|
651
|
+
modifiedHyperlinks: 0,
|
|
652
|
+
skippedHyperlinks: 0,
|
|
653
|
+
updatedUrls: 0,
|
|
654
|
+
updatedDisplayTexts: 0,
|
|
655
|
+
appendedContentIds: 0,
|
|
656
|
+
errorCount: 1,
|
|
657
|
+
errorMessages: [error instanceof Error ? error.message : "Processing failed"],
|
|
658
|
+
processedLinks: [],
|
|
659
|
+
validationIssues: [],
|
|
660
|
+
duration: 0,
|
|
661
|
+
} as HyperlinkProcessingResult;
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
// Batch processing with progress reporting
|
|
666
|
+
ipcMain.handle("hyperlink:batch-process", async (event, request) => {
|
|
667
|
+
const startTime = performance.now();
|
|
668
|
+
const results = new Map<string, HyperlinkProcessingResult>();
|
|
669
|
+
let totalHyperlinksProcessed = 0;
|
|
670
|
+
let totalHyperlinksModified = 0;
|
|
671
|
+
const errors: Array<{ file: string; error: string }> = [];
|
|
672
|
+
|
|
673
|
+
try {
|
|
674
|
+
// Validate all paths first
|
|
675
|
+
const validPaths = await Promise.all(
|
|
676
|
+
request.filePaths.map((fp: string) => this.validateFilePath(fp))
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
// Process files with controlled concurrency
|
|
680
|
+
const processedResults = await this.processor.batchProcess(validPaths, request.options);
|
|
681
|
+
|
|
682
|
+
// Aggregate results
|
|
683
|
+
for (const { file: filePath, result } of processedResults.results) {
|
|
684
|
+
results.set(filePath, result);
|
|
685
|
+
if (result.success) {
|
|
686
|
+
totalHyperlinksProcessed += result.processedHyperlinks;
|
|
687
|
+
totalHyperlinksModified += result.modifiedHyperlinks;
|
|
688
|
+
} else {
|
|
689
|
+
errors.push({
|
|
690
|
+
file: filePath,
|
|
691
|
+
error: result.errorMessages.join(", "),
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const summary = {
|
|
697
|
+
totalFiles: request.filePaths.length,
|
|
698
|
+
successfulFiles: Array.from(results.values()).filter((r) => r.success).length,
|
|
699
|
+
failedFiles: errors.length,
|
|
700
|
+
totalHyperlinksProcessed,
|
|
701
|
+
totalHyperlinksModified,
|
|
702
|
+
processingTimeMs: performance.now() - startTime,
|
|
703
|
+
errors,
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
return { results, summary } as BatchProcessingResult;
|
|
707
|
+
} catch (error) {
|
|
708
|
+
return {
|
|
709
|
+
results,
|
|
710
|
+
summary: {
|
|
711
|
+
totalFiles: request.filePaths.length,
|
|
712
|
+
successfulFiles: 0,
|
|
713
|
+
failedFiles: request.filePaths.length,
|
|
714
|
+
totalHyperlinksProcessed: 0,
|
|
715
|
+
totalHyperlinksModified: 0,
|
|
716
|
+
processingTimeMs: performance.now() - startTime,
|
|
717
|
+
errors: [
|
|
718
|
+
{
|
|
719
|
+
file: "batch",
|
|
720
|
+
error: error instanceof Error ? error.message : "Batch processing failed",
|
|
721
|
+
},
|
|
722
|
+
],
|
|
723
|
+
},
|
|
724
|
+
} as BatchProcessingResult;
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// Validate PowerAutomate API endpoint using net.request (respects proxy/Zscaler)
|
|
729
|
+
ipcMain.handle("hyperlink:validate-api", async (event, request) => {
|
|
730
|
+
try {
|
|
731
|
+
// SECURITY: Validate URL against domain allowlist to prevent SSRF
|
|
732
|
+
validateApiUrl(request.apiUrl);
|
|
733
|
+
|
|
734
|
+
const startTime = performance.now();
|
|
735
|
+
|
|
736
|
+
return new Promise((resolve) => {
|
|
737
|
+
const timeout = setTimeout(() => {
|
|
738
|
+
resolve({
|
|
739
|
+
isValid: false,
|
|
740
|
+
message: "Connection timeout after 5000ms",
|
|
741
|
+
responseTime: 5000,
|
|
742
|
+
});
|
|
743
|
+
}, 5000);
|
|
744
|
+
|
|
745
|
+
const netRequest = net.request({
|
|
746
|
+
method: "OPTIONS",
|
|
747
|
+
url: request.apiUrl,
|
|
748
|
+
session: session.defaultSession,
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
netRequest.on("response", (response) => {
|
|
752
|
+
clearTimeout(timeout);
|
|
753
|
+
const responseTime = performance.now() - startTime;
|
|
754
|
+
const isValid = (response.statusCode >= 200 && response.statusCode < 400) || response.statusCode === 405;
|
|
755
|
+
resolve({
|
|
756
|
+
isValid,
|
|
757
|
+
message: isValid
|
|
758
|
+
? "API endpoint is reachable"
|
|
759
|
+
: `API returned status ${response.statusCode}`,
|
|
760
|
+
responseTime,
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
netRequest.on("error", (error) => {
|
|
765
|
+
clearTimeout(timeout);
|
|
766
|
+
resolve({
|
|
767
|
+
isValid: false,
|
|
768
|
+
message: error.message || "Validation failed",
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
netRequest.end();
|
|
773
|
+
});
|
|
774
|
+
} catch (error) {
|
|
775
|
+
return {
|
|
776
|
+
isValid: false,
|
|
777
|
+
message: error instanceof Error ? error.message : "Validation failed",
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
// Call PowerAutomate API using Electron net.request (IPC-based approach)
|
|
783
|
+
// Uses Chromium's network stack which respects system proxy and certificates
|
|
784
|
+
ipcMain.handle("hyperlink:call-api", async (_event, request: {
|
|
785
|
+
apiUrl: string;
|
|
786
|
+
payload: {
|
|
787
|
+
Lookup_ID: string[];
|
|
788
|
+
Hyperlinks_Checked: number;
|
|
789
|
+
Total_Hyperlinks: number;
|
|
790
|
+
First_Name: string;
|
|
791
|
+
Last_Name: string;
|
|
792
|
+
Email: string;
|
|
793
|
+
};
|
|
794
|
+
timeout?: number;
|
|
795
|
+
}) => {
|
|
796
|
+
// SECURITY: Validate URL against domain allowlist to prevent SSRF
|
|
797
|
+
validateApiUrl(request.apiUrl);
|
|
798
|
+
|
|
799
|
+
const timeoutMs = request.timeout || 30000;
|
|
800
|
+
const jsonPayload = JSON.stringify(request.payload);
|
|
801
|
+
const startTime = Date.now();
|
|
802
|
+
|
|
803
|
+
// =========================================================================
|
|
804
|
+
// COMPREHENSIVE LOGGING - REQUEST DETAILS
|
|
805
|
+
// =========================================================================
|
|
806
|
+
log.info("═══════════════════════════════════════════════════════════════════");
|
|
807
|
+
log.info("[API Call] Starting Power Automate HTTP Request");
|
|
808
|
+
log.info("═══════════════════════════════════════════════════════════════════");
|
|
809
|
+
log.info(`[API Call] Timestamp: ${new Date().toISOString()}`);
|
|
810
|
+
log.info(`[API Call] URL: ${request.apiUrl}`);
|
|
811
|
+
log.info(`[API Call] Method: POST`);
|
|
812
|
+
log.info(`[API Call] Timeout: ${timeoutMs}ms`);
|
|
813
|
+
log.info(`[API Call] Headers:`);
|
|
814
|
+
log.info(`[API Call] Content-Type: application/json; charset=utf-8`);
|
|
815
|
+
log.info(`[API Call] User-Agent: DocHub/1.0`);
|
|
816
|
+
log.info(`[API Call] Accept: application/json`);
|
|
817
|
+
log.info(`[API Call] Payload:`);
|
|
818
|
+
// Log payload with PII fields redacted
|
|
819
|
+
const redactedPayload = {
|
|
820
|
+
...request.payload,
|
|
821
|
+
First_Name: "[REDACTED]",
|
|
822
|
+
Last_Name: "[REDACTED]",
|
|
823
|
+
Email: "[REDACTED]",
|
|
824
|
+
};
|
|
825
|
+
const payloadFormatted = JSON.stringify(redactedPayload, null, 2);
|
|
826
|
+
payloadFormatted.split('\n').forEach(line => {
|
|
827
|
+
log.info(`[API Call] ${line}`);
|
|
828
|
+
});
|
|
829
|
+
log.info(`[API Call] Payload size: ${Buffer.byteLength(jsonPayload)} bytes`);
|
|
830
|
+
log.info("───────────────────────────────────────────────────────────────────");
|
|
831
|
+
|
|
832
|
+
return new Promise((resolve) => {
|
|
833
|
+
log.info("[API Call] Sending request via Electron net.request...");
|
|
834
|
+
|
|
835
|
+
const timeoutHandle = setTimeout(() => {
|
|
836
|
+
const duration = Date.now() - startTime;
|
|
837
|
+
log.error("═══════════════════════════════════════════════════════════════════");
|
|
838
|
+
log.error("[API Call] REQUEST TIMEOUT");
|
|
839
|
+
log.error("═══════════════════════════════════════════════════════════════════");
|
|
840
|
+
log.error(`[API Call] Timeout after ${timeoutMs}ms`);
|
|
841
|
+
log.error(`[API Call] Duration: ${duration}ms`);
|
|
842
|
+
log.error("═══════════════════════════════════════════════════════════════════");
|
|
843
|
+
resolve({
|
|
844
|
+
success: false,
|
|
845
|
+
error: `Request timeout after ${timeoutMs}ms`,
|
|
846
|
+
duration,
|
|
847
|
+
});
|
|
848
|
+
}, timeoutMs);
|
|
849
|
+
|
|
850
|
+
try {
|
|
851
|
+
const netRequest = net.request({
|
|
852
|
+
method: "POST",
|
|
853
|
+
url: request.apiUrl,
|
|
854
|
+
session: session.defaultSession,
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// Set headers
|
|
858
|
+
netRequest.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
859
|
+
netRequest.setHeader("User-Agent", "DocHub/1.0");
|
|
860
|
+
netRequest.setHeader("Accept", "application/json");
|
|
861
|
+
|
|
862
|
+
let responseData = "";
|
|
863
|
+
let responseHeaders: Record<string, string | string[]> = {};
|
|
864
|
+
|
|
865
|
+
netRequest.on("response", (response) => {
|
|
866
|
+
log.info("[API Call] Response received, reading data...");
|
|
867
|
+
log.info(`[API Call] Status: ${response.statusCode} ${response.statusMessage}`);
|
|
868
|
+
|
|
869
|
+
// Capture response headers
|
|
870
|
+
responseHeaders = response.headers;
|
|
871
|
+
log.info(`[API Call] Response Headers:`);
|
|
872
|
+
Object.entries(response.headers).forEach(([key, value]) => {
|
|
873
|
+
log.info(`[API Call] ${key}: ${Array.isArray(value) ? value.join(', ') : value}`);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
response.on("data", (chunk) => {
|
|
877
|
+
responseData += chunk.toString();
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
response.on("end", () => {
|
|
881
|
+
clearTimeout(timeoutHandle);
|
|
882
|
+
const duration = Date.now() - startTime;
|
|
883
|
+
|
|
884
|
+
log.info("───────────────────────────────────────────────────────────────────");
|
|
885
|
+
log.info(`[API Call] Response Body (raw, ${responseData.length} chars):`);
|
|
886
|
+
// Log response body with proper formatting
|
|
887
|
+
if (responseData.length > 0) {
|
|
888
|
+
try {
|
|
889
|
+
const parsedResponse = JSON.parse(responseData);
|
|
890
|
+
const responseFormatted = JSON.stringify(parsedResponse, null, 2);
|
|
891
|
+
responseFormatted.split('\n').forEach(line => {
|
|
892
|
+
log.info(`[API Call] ${line}`);
|
|
893
|
+
});
|
|
894
|
+
} catch {
|
|
895
|
+
// If not JSON, log as-is (truncated if too long)
|
|
896
|
+
const truncated = responseData.length > 1000 ? responseData.substring(0, 1000) + '...' : responseData;
|
|
897
|
+
log.info(`[API Call] ${truncated}`);
|
|
898
|
+
}
|
|
899
|
+
} else {
|
|
900
|
+
log.info(`[API Call] (empty response body)`);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (response.statusCode && response.statusCode >= 200 && response.statusCode < 300) {
|
|
904
|
+
try {
|
|
905
|
+
const data = JSON.parse(responseData);
|
|
906
|
+
log.info("═══════════════════════════════════════════════════════════════════");
|
|
907
|
+
log.info("[API Call] SUCCESS");
|
|
908
|
+
log.info("═══════════════════════════════════════════════════════════════════");
|
|
909
|
+
log.info(`[API Call] Status Code: ${response.statusCode}`);
|
|
910
|
+
log.info(`[API Call] Duration: ${duration}ms`);
|
|
911
|
+
if (data.Results) {
|
|
912
|
+
log.info(`[API Call] Results count: ${data.Results.length}`);
|
|
913
|
+
}
|
|
914
|
+
log.info("═══════════════════════════════════════════════════════════════════");
|
|
915
|
+
|
|
916
|
+
resolve({
|
|
917
|
+
success: true,
|
|
918
|
+
statusCode: response.statusCode,
|
|
919
|
+
data,
|
|
920
|
+
duration,
|
|
921
|
+
method: "net.request",
|
|
922
|
+
});
|
|
923
|
+
} catch (parseError) {
|
|
924
|
+
log.error("═══════════════════════════════════════════════════════════════════");
|
|
925
|
+
log.error("[API Call] JSON PARSE ERROR");
|
|
926
|
+
log.error("═══════════════════════════════════════════════════════════════════");
|
|
927
|
+
log.error(`[API Call] Failed to parse response as JSON`);
|
|
928
|
+
log.error(`[API Call] Parse error: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
|
|
929
|
+
log.error(`[API Call] Raw response: ${responseData.substring(0, 500)}`);
|
|
930
|
+
log.error(`[API Call] Duration: ${duration}ms`);
|
|
931
|
+
log.error("═══════════════════════════════════════════════════════════════════");
|
|
932
|
+
|
|
933
|
+
resolve({
|
|
934
|
+
success: false,
|
|
935
|
+
error: "Failed to parse response JSON",
|
|
936
|
+
rawResponse: responseData.substring(0, 500),
|
|
937
|
+
duration,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
} else {
|
|
941
|
+
log.error("═══════════════════════════════════════════════════════════════════");
|
|
942
|
+
log.error("[API Call] HTTP ERROR");
|
|
943
|
+
log.error("═══════════════════════════════════════════════════════════════════");
|
|
944
|
+
log.error(`[API Call] Status Code: ${response.statusCode}`);
|
|
945
|
+
log.error(`[API Call] Status Message: ${response.statusMessage}`);
|
|
946
|
+
log.error(`[API Call] Response Body: ${responseData.substring(0, 500)}`);
|
|
947
|
+
log.error(`[API Call] Duration: ${duration}ms`);
|
|
948
|
+
log.error("═══════════════════════════════════════════════════════════════════");
|
|
949
|
+
|
|
950
|
+
resolve({
|
|
951
|
+
success: false,
|
|
952
|
+
error: `HTTP ${response.statusCode}: ${response.statusMessage}`,
|
|
953
|
+
statusCode: response.statusCode,
|
|
954
|
+
rawResponse: responseData.substring(0, 500),
|
|
955
|
+
duration,
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
netRequest.on("error", (error) => {
|
|
962
|
+
clearTimeout(timeoutHandle);
|
|
963
|
+
const duration = Date.now() - startTime;
|
|
964
|
+
|
|
965
|
+
log.error("═══════════════════════════════════════════════════════════════════");
|
|
966
|
+
log.error("[API Call] NETWORK ERROR");
|
|
967
|
+
log.error("═══════════════════════════════════════════════════════════════════");
|
|
968
|
+
log.error(`[API Call] Error Type: ${error.name || 'Unknown'}`);
|
|
969
|
+
log.error(`[API Call] Error Message: ${error.message}`);
|
|
970
|
+
if (error.stack) {
|
|
971
|
+
log.error(`[API Call] Stack Trace:`);
|
|
972
|
+
error.stack.split('\n').forEach(line => {
|
|
973
|
+
log.error(`[API Call] ${line}`);
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
log.error(`[API Call] Duration: ${duration}ms`);
|
|
977
|
+
log.error("[API Call] Possible causes:");
|
|
978
|
+
log.error("[API Call] - Network connectivity issues");
|
|
979
|
+
log.error("[API Call] - Corporate proxy blocking the request");
|
|
980
|
+
log.error("[API Call] - SSL/TLS certificate issues");
|
|
981
|
+
log.error("[API Call] - DNS resolution failure");
|
|
982
|
+
log.error("[API Call] - Firewall blocking outbound connections");
|
|
983
|
+
log.error("═══════════════════════════════════════════════════════════════════");
|
|
984
|
+
|
|
985
|
+
resolve({
|
|
986
|
+
success: false,
|
|
987
|
+
error: error.message,
|
|
988
|
+
errorType: error.name,
|
|
989
|
+
duration,
|
|
990
|
+
});
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
// Write payload and send request
|
|
994
|
+
log.info("[API Call] Writing payload and sending request...");
|
|
995
|
+
netRequest.write(jsonPayload);
|
|
996
|
+
netRequest.end();
|
|
997
|
+
log.info("[API Call] Request sent, waiting for response...");
|
|
998
|
+
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
clearTimeout(timeoutHandle);
|
|
1001
|
+
const duration = Date.now() - startTime;
|
|
1002
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1003
|
+
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
1004
|
+
|
|
1005
|
+
log.error("═══════════════════════════════════════════════════════════════════");
|
|
1006
|
+
log.error("[API Call] EXCEPTION DURING REQUEST");
|
|
1007
|
+
log.error("═══════════════════════════════════════════════════════════════════");
|
|
1008
|
+
log.error(`[API Call] Error: ${errorMessage}`);
|
|
1009
|
+
if (errorStack) {
|
|
1010
|
+
log.error(`[API Call] Stack Trace:`);
|
|
1011
|
+
errorStack.split('\n').forEach(line => {
|
|
1012
|
+
log.error(`[API Call] ${line}`);
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
log.error(`[API Call] Duration: ${duration}ms`);
|
|
1016
|
+
log.error("═══════════════════════════════════════════════════════════════════");
|
|
1017
|
+
|
|
1018
|
+
resolve({
|
|
1019
|
+
success: false,
|
|
1020
|
+
error: errorMessage,
|
|
1021
|
+
duration,
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
// Cancel ongoing operation
|
|
1028
|
+
ipcMain.handle("hyperlink:cancel-operation", async (event, request) => {
|
|
1029
|
+
const controller = this.processingQueue.get(request.operationId);
|
|
1030
|
+
if (controller) {
|
|
1031
|
+
controller.abort();
|
|
1032
|
+
this.processingQueue.delete(request.operationId);
|
|
1033
|
+
return { success: true, message: "Operation cancelled" };
|
|
1034
|
+
}
|
|
1035
|
+
return { success: false, message: "Operation not found" };
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
// File selection dialog
|
|
1039
|
+
ipcMain.handle("hyperlink:select-files", async () => {
|
|
1040
|
+
const result = await dialog.showOpenDialog(mainWindow!, {
|
|
1041
|
+
title: "Select Word Documents",
|
|
1042
|
+
filters: [
|
|
1043
|
+
{ name: "Word Documents", extensions: ["docx"] },
|
|
1044
|
+
{ name: "All Files", extensions: ["*"] },
|
|
1045
|
+
],
|
|
1046
|
+
properties: ["openFile", "multiSelections"],
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
if (!result.canceled) {
|
|
1050
|
+
return result.filePaths;
|
|
1051
|
+
}
|
|
1052
|
+
return [];
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private async validateFilePath(filePath: string): Promise<string> {
|
|
1057
|
+
// SECURITY: Check for path traversal attempts before normalization
|
|
1058
|
+
if (filePath.includes("..")) {
|
|
1059
|
+
throw new Error('Path traversal detected - relative paths with ".." are not allowed');
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// Normalize and validate path
|
|
1063
|
+
const normalizedPath = join(filePath);
|
|
1064
|
+
|
|
1065
|
+
// SECURITY: Double-check after normalization (defense in depth)
|
|
1066
|
+
if (normalizedPath.includes("..")) {
|
|
1067
|
+
throw new Error("Path traversal detected after normalization");
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Check if file exists
|
|
1071
|
+
try {
|
|
1072
|
+
const stats = await fsPromises.stat(normalizedPath);
|
|
1073
|
+
if (!stats.isFile()) {
|
|
1074
|
+
throw new Error("Path is not a file");
|
|
1075
|
+
}
|
|
1076
|
+
} catch (error) {
|
|
1077
|
+
throw new Error(`Invalid file path: ${filePath}`);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Validate file extension
|
|
1081
|
+
if (!normalizedPath.toLowerCase().endsWith(".docx")) {
|
|
1082
|
+
throw new Error("Only .docx files are supported");
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Check file size (max 100MB)
|
|
1086
|
+
const stats = await fsPromises.stat(normalizedPath);
|
|
1087
|
+
const maxSize = 100 * 1024 * 1024; // 100MB
|
|
1088
|
+
if (stats.size > maxSize) {
|
|
1089
|
+
throw new Error("File size exceeds 100MB limit");
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
return normalizedPath;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
private async processWithTimeout<T>(
|
|
1096
|
+
promise: Promise<T>,
|
|
1097
|
+
signal: AbortSignal,
|
|
1098
|
+
timeoutMs: number
|
|
1099
|
+
): Promise<T> {
|
|
1100
|
+
return Promise.race([
|
|
1101
|
+
promise,
|
|
1102
|
+
new Promise<T>((_, reject) => {
|
|
1103
|
+
const timeout = setTimeout(() => {
|
|
1104
|
+
reject(new Error(`Operation timed out after ${timeoutMs}ms`));
|
|
1105
|
+
}, timeoutMs);
|
|
1106
|
+
|
|
1107
|
+
signal.addEventListener("abort", () => {
|
|
1108
|
+
clearTimeout(timeout);
|
|
1109
|
+
reject(new Error("Operation was cancelled"));
|
|
1110
|
+
});
|
|
1111
|
+
}),
|
|
1112
|
+
]);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Initialize IPC handlers
|
|
1117
|
+
const hyperlinkHandler = new HyperlinkIPCHandler();
|
|
1118
|
+
|
|
1119
|
+
// Enhanced security settings
|
|
1120
|
+
app.on("web-contents-created", (event, contents) => {
|
|
1121
|
+
// Prevent new window creation
|
|
1122
|
+
contents.setWindowOpenHandler(() => {
|
|
1123
|
+
return { action: "deny" };
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
// Prevent navigation to external URLs
|
|
1127
|
+
contents.on("will-navigate", (event, url) => {
|
|
1128
|
+
if (!url.startsWith("http://localhost:") && !url.startsWith("file://")) {
|
|
1129
|
+
event.preventDefault();
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
ipcMain.handle("platform", () => {
|
|
1135
|
+
return process.platform;
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
// File handling
|
|
1139
|
+
ipcMain.handle("select-documents", async () => {
|
|
1140
|
+
const result = await dialog.showOpenDialog(mainWindow!, {
|
|
1141
|
+
properties: ["openFile", "multiSelections"],
|
|
1142
|
+
filters: [
|
|
1143
|
+
{ name: "Word Documents", extensions: ["docx"] },
|
|
1144
|
+
{ name: "All Files", extensions: ["*"] },
|
|
1145
|
+
],
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
if (!result.canceled) {
|
|
1149
|
+
return result.filePaths;
|
|
1150
|
+
}
|
|
1151
|
+
return undefined;
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
// Show file in folder
|
|
1155
|
+
ipcMain.handle("show-in-folder", async (...[, filePath]: [Electron.IpcMainInvokeEvent, string]) => {
|
|
1156
|
+
try {
|
|
1157
|
+
const validatedPath = validateIpcPath(filePath, { requireExists: true });
|
|
1158
|
+
|
|
1159
|
+
// Show the file in the system file explorer
|
|
1160
|
+
shell.showItemInFolder(validatedPath);
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
log.error("Error showing file in folder:", error);
|
|
1163
|
+
throw error;
|
|
1164
|
+
}
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
// Open document in default application (Microsoft Word for .docx)
|
|
1168
|
+
ipcMain.handle("open-document", async (...[, filePath]: [Electron.IpcMainInvokeEvent, string]) => {
|
|
1169
|
+
try {
|
|
1170
|
+
const validatedPath = validateIpcPath(filePath, {
|
|
1171
|
+
requireExists: true,
|
|
1172
|
+
mustBeFile: true,
|
|
1173
|
+
allowedExtensions: [".docx"],
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
// Open the document in its default application
|
|
1177
|
+
const errorMessage = await shell.openPath(validatedPath);
|
|
1178
|
+
|
|
1179
|
+
// shell.openPath returns an empty string on success, or an error message on failure
|
|
1180
|
+
if (errorMessage) {
|
|
1181
|
+
throw new Error(`Failed to open document: ${errorMessage}`);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
log.info(`Successfully opened document in default application`);
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
log.error("Error opening document:", error);
|
|
1187
|
+
throw error;
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
// Open external URL in default browser (SECURITY: https-only)
|
|
1192
|
+
ipcMain.handle("open-external", async (...[, url]: [Electron.IpcMainInvokeEvent, string]) => {
|
|
1193
|
+
if (!url || typeof url !== "string") {
|
|
1194
|
+
throw new Error("No URL provided");
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// SECURITY: Only allow https: and mailto: protocols to prevent file://, smb://, etc.
|
|
1198
|
+
let parsed: URL;
|
|
1199
|
+
try {
|
|
1200
|
+
parsed = new URL(url);
|
|
1201
|
+
} catch {
|
|
1202
|
+
throw new Error("Invalid URL");
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const allowedProtocols = ["https:", "mailto:"];
|
|
1206
|
+
if (!allowedProtocols.includes(parsed.protocol)) {
|
|
1207
|
+
throw new Error(`Blocked protocol: ${parsed.protocol}. Only HTTPS and mailto links are allowed.`);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
await shell.openExternal(url);
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
// Get file statistics (size, modified date, etc.)
|
|
1214
|
+
ipcMain.handle("get-file-stats", async (...[, filePath]: [Electron.IpcMainInvokeEvent, string]) => {
|
|
1215
|
+
try {
|
|
1216
|
+
const validatedPath = validateIpcPath(filePath, { requireExists: true });
|
|
1217
|
+
|
|
1218
|
+
// Get file stats
|
|
1219
|
+
const stats = await fsPromises.stat(validatedPath);
|
|
1220
|
+
|
|
1221
|
+
return {
|
|
1222
|
+
size: stats.size,
|
|
1223
|
+
created: stats.birthtime,
|
|
1224
|
+
modified: stats.mtime,
|
|
1225
|
+
isFile: stats.isFile(),
|
|
1226
|
+
isDirectory: stats.isDirectory(),
|
|
1227
|
+
};
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
log.error("Error getting file stats:", error);
|
|
1230
|
+
throw error;
|
|
1231
|
+
}
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
// Restore document from backup
|
|
1235
|
+
ipcMain.handle(
|
|
1236
|
+
"restore-from-backup",
|
|
1237
|
+
async (
|
|
1238
|
+
...[, request]: [Electron.IpcMainInvokeEvent, { backupPath: string; targetPath: string }]
|
|
1239
|
+
) => {
|
|
1240
|
+
if (!request.backupPath || !request.targetPath) {
|
|
1241
|
+
throw new Error("Both backupPath and targetPath are required");
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
try {
|
|
1245
|
+
const validatedBackup = validateIpcPath(request.backupPath, {
|
|
1246
|
+
requireExists: true,
|
|
1247
|
+
mustBeFile: true,
|
|
1248
|
+
allowedExtensions: [".docx"],
|
|
1249
|
+
});
|
|
1250
|
+
const validatedTarget = validateIpcPath(request.targetPath, {
|
|
1251
|
+
allowedExtensions: [".docx"],
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
// Copy backup to target location, overwriting existing file
|
|
1255
|
+
await fsPromises.copyFile(validatedBackup, validatedTarget);
|
|
1256
|
+
|
|
1257
|
+
log.info("[Restore] Successfully restored document from backup");
|
|
1258
|
+
} catch (error) {
|
|
1259
|
+
log.error("Error restoring from backup:", error);
|
|
1260
|
+
throw error;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
);
|
|
1264
|
+
|
|
1265
|
+
ipcMain.handle("process-document", async (...[, path]: [Electron.IpcMainInvokeEvent, string]) => {
|
|
1266
|
+
if (!path) {
|
|
1267
|
+
return { success: false, error: "No path provided" };
|
|
1268
|
+
}
|
|
1269
|
+
try {
|
|
1270
|
+
const stats = fs.statSync(path);
|
|
1271
|
+
return {
|
|
1272
|
+
success: true,
|
|
1273
|
+
size: stats.size,
|
|
1274
|
+
processed: true,
|
|
1275
|
+
};
|
|
1276
|
+
} catch (error) {
|
|
1277
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1278
|
+
return {
|
|
1279
|
+
success: false,
|
|
1280
|
+
error: message,
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
// Read file as ArrayBuffer (for snapshot capture in renderer process)
|
|
1286
|
+
ipcMain.handle(
|
|
1287
|
+
"file:read-buffer",
|
|
1288
|
+
async (...[, filePath]: [Electron.IpcMainInvokeEvent, string]) => {
|
|
1289
|
+
try {
|
|
1290
|
+
const validatedPath = validateIpcPath(filePath, {
|
|
1291
|
+
requireExists: true,
|
|
1292
|
+
mustBeFile: true,
|
|
1293
|
+
allowedExtensions: [".docx"],
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
// Read file as buffer
|
|
1297
|
+
const buffer = await fsPromises.readFile(validatedPath);
|
|
1298
|
+
// Return as ArrayBuffer (Uint8Array is transferable via IPC)
|
|
1299
|
+
return buffer;
|
|
1300
|
+
} catch (error) {
|
|
1301
|
+
log.error("[IPC] Error reading file as buffer:", error);
|
|
1302
|
+
throw error;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
);
|
|
1306
|
+
|
|
1307
|
+
// Extract text content from a document (for comparison views)
|
|
1308
|
+
ipcMain.handle(
|
|
1309
|
+
"document:extract-text",
|
|
1310
|
+
async (...[, filePath]: [Electron.IpcMainInvokeEvent, string]) => {
|
|
1311
|
+
if (!filePath) {
|
|
1312
|
+
return { success: false, error: "No file path provided" };
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
try {
|
|
1316
|
+
// Validate file exists
|
|
1317
|
+
if (!fs.existsSync(filePath)) {
|
|
1318
|
+
return { success: false, error: `File not found: ${filePath}` };
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// Validate file extension
|
|
1322
|
+
if (!filePath.toLowerCase().endsWith(".docx")) {
|
|
1323
|
+
return { success: false, error: "Only .docx files are supported" };
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// Load document using docxmlater
|
|
1327
|
+
const { Document } = await import("docxmlater");
|
|
1328
|
+
const doc = await Document.load(filePath);
|
|
1329
|
+
|
|
1330
|
+
try {
|
|
1331
|
+
// Extract paragraph text
|
|
1332
|
+
const paragraphs = doc.getAllParagraphs();
|
|
1333
|
+
const textContent = paragraphs.map((para: any) => {
|
|
1334
|
+
try {
|
|
1335
|
+
return para.getText() || "";
|
|
1336
|
+
} catch {
|
|
1337
|
+
return "";
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
log.info(`[Document] Extracted text from ${filePath}: ${textContent.length} paragraphs`);
|
|
1342
|
+
|
|
1343
|
+
return {
|
|
1344
|
+
success: true,
|
|
1345
|
+
textContent,
|
|
1346
|
+
};
|
|
1347
|
+
} finally {
|
|
1348
|
+
// Always dispose document to free memory
|
|
1349
|
+
try {
|
|
1350
|
+
doc.dispose();
|
|
1351
|
+
} catch {
|
|
1352
|
+
// Ignore disposal errors
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
} catch (error) {
|
|
1356
|
+
log.error("[Document] Error extracting text:", error);
|
|
1357
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1358
|
+
return {
|
|
1359
|
+
success: false,
|
|
1360
|
+
error: message,
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
);
|
|
1365
|
+
|
|
1366
|
+
// Export/Import Settings
|
|
1367
|
+
ipcMain.handle("export-settings", async () => {
|
|
1368
|
+
try {
|
|
1369
|
+
const result = await dialog.showSaveDialog(mainWindow!, {
|
|
1370
|
+
title: "Export Settings and Data",
|
|
1371
|
+
defaultPath: `DocHub-Export-${new Date().toISOString().split("T")[0]}.json`,
|
|
1372
|
+
filters: [
|
|
1373
|
+
{ name: "JSON Files", extensions: ["json"] },
|
|
1374
|
+
{ name: "All Files", extensions: ["*"] },
|
|
1375
|
+
],
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
if (!result.canceled && result.filePath) {
|
|
1379
|
+
return {
|
|
1380
|
+
success: true,
|
|
1381
|
+
filePath: result.filePath,
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
return { success: false, canceled: true };
|
|
1386
|
+
} catch (error) {
|
|
1387
|
+
log.error("Error showing export dialog:", error);
|
|
1388
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1389
|
+
return { success: false, error: message };
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
ipcMain.handle("import-settings", async () => {
|
|
1394
|
+
try {
|
|
1395
|
+
const result = await dialog.showOpenDialog(mainWindow!, {
|
|
1396
|
+
title: "Import Settings and Data",
|
|
1397
|
+
filters: [
|
|
1398
|
+
{ name: "JSON Files", extensions: ["json"] },
|
|
1399
|
+
{ name: "All Files", extensions: ["*"] },
|
|
1400
|
+
],
|
|
1401
|
+
properties: ["openFile"],
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
if (!result.canceled && result.filePaths.length > 0) {
|
|
1405
|
+
const filePath = result.filePaths[0];
|
|
1406
|
+
const fileContent = await fsPromises.readFile(filePath, "utf-8");
|
|
1407
|
+
|
|
1408
|
+
return {
|
|
1409
|
+
success: true,
|
|
1410
|
+
data: JSON.parse(fileContent),
|
|
1411
|
+
filePath,
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
return { success: false, canceled: true };
|
|
1416
|
+
} catch (error) {
|
|
1417
|
+
log.error("Error importing settings:", error);
|
|
1418
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1419
|
+
return { success: false, error: message };
|
|
1420
|
+
}
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
ipcMain.handle(
|
|
1424
|
+
"save-export-data",
|
|
1425
|
+
async (...[, request]: [Electron.IpcMainInvokeEvent, { filePath: string; data: any }]) => {
|
|
1426
|
+
try {
|
|
1427
|
+
const validatedPath = validateIpcPath(request.filePath, {
|
|
1428
|
+
allowedExtensions: [".json"],
|
|
1429
|
+
});
|
|
1430
|
+
await fsPromises.writeFile(validatedPath, JSON.stringify(request.data, null, 2), "utf-8");
|
|
1431
|
+
return { success: true };
|
|
1432
|
+
} catch (error) {
|
|
1433
|
+
log.error("Error saving export data:", error);
|
|
1434
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1435
|
+
return { success: false, error: message };
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
);
|
|
1439
|
+
|
|
1440
|
+
// ==============================================================================
|
|
1441
|
+
// Backup Service IPC Handlers
|
|
1442
|
+
// ==============================================================================
|
|
1443
|
+
|
|
1444
|
+
// Initialize backup service (singleton instance)
|
|
1445
|
+
const backupService = new BackupService();
|
|
1446
|
+
|
|
1447
|
+
// Create backup
|
|
1448
|
+
ipcMain.handle(
|
|
1449
|
+
"backup:create",
|
|
1450
|
+
async (...[, documentPath]: [Electron.IpcMainInvokeEvent, string]) => {
|
|
1451
|
+
try {
|
|
1452
|
+
if (!documentPath || typeof documentPath !== "string") {
|
|
1453
|
+
throw new Error("Invalid document path");
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
const backupPath = await backupService.createBackup(documentPath);
|
|
1457
|
+
return { success: true, backupPath };
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
log.error("[Backup] Create backup failed:", error);
|
|
1460
|
+
const message = error instanceof Error ? error.message : "Failed to create backup";
|
|
1461
|
+
return { success: false, error: message };
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
);
|
|
1465
|
+
|
|
1466
|
+
// Restore from backup
|
|
1467
|
+
ipcMain.handle(
|
|
1468
|
+
"backup:restore",
|
|
1469
|
+
async (
|
|
1470
|
+
...[, request]: [Electron.IpcMainInvokeEvent, { backupPath: string; targetPath: string }]
|
|
1471
|
+
) => {
|
|
1472
|
+
try {
|
|
1473
|
+
if (!request.backupPath || !request.targetPath) {
|
|
1474
|
+
throw new Error("Both backupPath and targetPath are required");
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
await backupService.restoreBackup(request.backupPath, request.targetPath);
|
|
1478
|
+
return { success: true };
|
|
1479
|
+
} catch (error) {
|
|
1480
|
+
log.error("[Backup] Restore backup failed:", error);
|
|
1481
|
+
const message = error instanceof Error ? error.message : "Failed to restore backup";
|
|
1482
|
+
return { success: false, error: message };
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
);
|
|
1486
|
+
|
|
1487
|
+
// List backups for document
|
|
1488
|
+
ipcMain.handle(
|
|
1489
|
+
"backup:list",
|
|
1490
|
+
async (...[, documentPath]: [Electron.IpcMainInvokeEvent, string]) => {
|
|
1491
|
+
try {
|
|
1492
|
+
if (!documentPath || typeof documentPath !== "string") {
|
|
1493
|
+
throw new Error("Invalid document path");
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
const backups = await backupService.listBackups(documentPath);
|
|
1497
|
+
return { success: true, backups };
|
|
1498
|
+
} catch (error) {
|
|
1499
|
+
log.error("[Backup] List backups failed:", error);
|
|
1500
|
+
const message = error instanceof Error ? error.message : "Failed to list backups";
|
|
1501
|
+
return { success: false, error: message, backups: [] };
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
);
|
|
1505
|
+
|
|
1506
|
+
// Delete specific backup
|
|
1507
|
+
ipcMain.handle(
|
|
1508
|
+
"backup:delete",
|
|
1509
|
+
async (...[, backupPath]: [Electron.IpcMainInvokeEvent, string]) => {
|
|
1510
|
+
try {
|
|
1511
|
+
if (!backupPath || typeof backupPath !== "string") {
|
|
1512
|
+
throw new Error("Invalid backup path");
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
await backupService.deleteBackup(backupPath);
|
|
1516
|
+
return { success: true };
|
|
1517
|
+
} catch (error) {
|
|
1518
|
+
log.error("[Backup] Delete backup failed:", error);
|
|
1519
|
+
const message = error instanceof Error ? error.message : "Failed to delete backup";
|
|
1520
|
+
return { success: false, error: message };
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
);
|
|
1524
|
+
|
|
1525
|
+
// Cleanup old backups for document
|
|
1526
|
+
ipcMain.handle(
|
|
1527
|
+
"backup:cleanup",
|
|
1528
|
+
async (...[, documentPath]: [Electron.IpcMainInvokeEvent, string]) => {
|
|
1529
|
+
try {
|
|
1530
|
+
if (!documentPath || typeof documentPath !== "string") {
|
|
1531
|
+
throw new Error("Invalid document path");
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
const deletedCount = await backupService.cleanupOldBackups(documentPath);
|
|
1535
|
+
return { success: true, deletedCount };
|
|
1536
|
+
} catch (error) {
|
|
1537
|
+
log.error("[Backup] Cleanup backups failed:", error);
|
|
1538
|
+
const message = error instanceof Error ? error.message : "Failed to cleanup backups";
|
|
1539
|
+
return { success: false, error: message, deletedCount: 0 };
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
);
|
|
1543
|
+
|
|
1544
|
+
// Cleanup all old backups
|
|
1545
|
+
ipcMain.handle("backup:cleanup-all", async () => {
|
|
1546
|
+
try {
|
|
1547
|
+
const deletedCount = await backupService.cleanupAllOldBackups();
|
|
1548
|
+
return { success: true, deletedCount };
|
|
1549
|
+
} catch (error) {
|
|
1550
|
+
log.error("[Backup] Cleanup all backups failed:", error);
|
|
1551
|
+
const message = error instanceof Error ? error.message : "Failed to cleanup all backups";
|
|
1552
|
+
return { success: false, error: message, deletedCount: 0 };
|
|
1553
|
+
}
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
// Verify backup integrity
|
|
1557
|
+
ipcMain.handle(
|
|
1558
|
+
"backup:verify",
|
|
1559
|
+
async (...[, backupPath]: [Electron.IpcMainInvokeEvent, string]) => {
|
|
1560
|
+
try {
|
|
1561
|
+
if (!backupPath || typeof backupPath !== "string") {
|
|
1562
|
+
throw new Error("Invalid backup path");
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
const isValid = await backupService.verifyBackup(backupPath);
|
|
1566
|
+
return { success: true, isValid };
|
|
1567
|
+
} catch (error) {
|
|
1568
|
+
log.error("[Backup] Verify backup failed:", error);
|
|
1569
|
+
const message = error instanceof Error ? error.message : "Failed to verify backup";
|
|
1570
|
+
return { success: false, error: message, isValid: false };
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
);
|
|
1574
|
+
|
|
1575
|
+
// Get backup storage info
|
|
1576
|
+
ipcMain.handle("backup:storage-info", async () => {
|
|
1577
|
+
try {
|
|
1578
|
+
const storageInfo = await backupService.getBackupStorageInfo();
|
|
1579
|
+
return { success: true, storageInfo };
|
|
1580
|
+
} catch (error) {
|
|
1581
|
+
log.error("[Backup] Get storage info failed:", error);
|
|
1582
|
+
const message = error instanceof Error ? error.message : "Failed to get storage info";
|
|
1583
|
+
return { success: false, error: message };
|
|
1584
|
+
}
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
// Set backup configuration
|
|
1588
|
+
ipcMain.handle(
|
|
1589
|
+
"backup:set-config",
|
|
1590
|
+
async (...[, config]: [Electron.IpcMainInvokeEvent, Partial<BackupConfig>]) => {
|
|
1591
|
+
try {
|
|
1592
|
+
if (!config || typeof config !== "object") {
|
|
1593
|
+
throw new Error("Invalid backup configuration");
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
backupService.setConfig(config);
|
|
1597
|
+
return { success: true };
|
|
1598
|
+
} catch (error) {
|
|
1599
|
+
log.error("[Backup] Set config failed:", error);
|
|
1600
|
+
const message = error instanceof Error ? error.message : "Failed to set backup configuration";
|
|
1601
|
+
return { success: false, error: message };
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
);
|
|
1605
|
+
|
|
1606
|
+
// ==============================================================================
|
|
1607
|
+
// Dictionary Service IPC Handlers
|
|
1608
|
+
// ==============================================================================
|
|
1609
|
+
|
|
1610
|
+
// Initialize dictionary database
|
|
1611
|
+
ipcMain.handle("dictionary:initialize", async () => {
|
|
1612
|
+
try {
|
|
1613
|
+
const dictionaryService = getDictionaryService();
|
|
1614
|
+
const result = await dictionaryService.initialize();
|
|
1615
|
+
return result;
|
|
1616
|
+
} catch (error) {
|
|
1617
|
+
log.error("[Dictionary] Initialize failed:", error);
|
|
1618
|
+
const message = error instanceof Error ? error.message : "Failed to initialize dictionary";
|
|
1619
|
+
return { success: false, totalEntries: 0, error: message };
|
|
1620
|
+
}
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
// Configure SharePoint sync
|
|
1624
|
+
ipcMain.handle(
|
|
1625
|
+
"dictionary:configure-sync",
|
|
1626
|
+
async (...[, config]: [Electron.IpcMainInvokeEvent, SharePointConfig]) => {
|
|
1627
|
+
try {
|
|
1628
|
+
const syncService = getSharePointSyncService();
|
|
1629
|
+
syncService.configure(config);
|
|
1630
|
+
|
|
1631
|
+
// Set main window for progress updates
|
|
1632
|
+
if (mainWindow) {
|
|
1633
|
+
syncService.setMainWindow(mainWindow);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
return { success: true };
|
|
1637
|
+
} catch (error) {
|
|
1638
|
+
log.error("[Dictionary] Configure sync failed:", error);
|
|
1639
|
+
const message = error instanceof Error ? error.message : "Failed to configure sync";
|
|
1640
|
+
return { success: false, error: message };
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
);
|
|
1644
|
+
|
|
1645
|
+
// Set client secret (sensitive, not stored in settings)
|
|
1646
|
+
ipcMain.handle(
|
|
1647
|
+
"dictionary:set-credentials",
|
|
1648
|
+
async (...[, secret]: [Electron.IpcMainInvokeEvent, string]) => {
|
|
1649
|
+
try {
|
|
1650
|
+
const syncService = getSharePointSyncService();
|
|
1651
|
+
const result = syncService.setClientSecret(secret);
|
|
1652
|
+
return result;
|
|
1653
|
+
} catch (error) {
|
|
1654
|
+
log.error("[Dictionary] Set credentials failed:", error);
|
|
1655
|
+
const message = error instanceof Error ? error.message : "Failed to set credentials";
|
|
1656
|
+
return { success: false, error: message };
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
);
|
|
1660
|
+
|
|
1661
|
+
// Trigger dictionary sync
|
|
1662
|
+
ipcMain.handle("dictionary:sync", async () => {
|
|
1663
|
+
try {
|
|
1664
|
+
const syncService = getSharePointSyncService();
|
|
1665
|
+
const result = await syncService.sync();
|
|
1666
|
+
return result;
|
|
1667
|
+
} catch (error) {
|
|
1668
|
+
log.error("[Dictionary] Sync failed:", error);
|
|
1669
|
+
const message = error instanceof Error ? error.message : "Failed to sync dictionary";
|
|
1670
|
+
return { success: false, entriesImported: 0, duration: 0, error: message };
|
|
1671
|
+
}
|
|
1672
|
+
});
|
|
1673
|
+
|
|
1674
|
+
// Start sync scheduler
|
|
1675
|
+
ipcMain.handle(
|
|
1676
|
+
"dictionary:start-scheduler",
|
|
1677
|
+
async (...[, intervalHours]: [Electron.IpcMainInvokeEvent, number]) => {
|
|
1678
|
+
try {
|
|
1679
|
+
const syncService = getSharePointSyncService();
|
|
1680
|
+
syncService.startScheduler(intervalHours);
|
|
1681
|
+
return { success: true };
|
|
1682
|
+
} catch (error) {
|
|
1683
|
+
log.error("[Dictionary] Start scheduler failed:", error);
|
|
1684
|
+
const message = error instanceof Error ? error.message : "Failed to start scheduler";
|
|
1685
|
+
return { success: false, error: message };
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
);
|
|
1689
|
+
|
|
1690
|
+
// Stop sync scheduler
|
|
1691
|
+
ipcMain.handle("dictionary:stop-scheduler", async () => {
|
|
1692
|
+
try {
|
|
1693
|
+
const syncService = getSharePointSyncService();
|
|
1694
|
+
syncService.stopScheduler();
|
|
1695
|
+
return { success: true };
|
|
1696
|
+
} catch (error) {
|
|
1697
|
+
log.error("[Dictionary] Stop scheduler failed:", error);
|
|
1698
|
+
const message = error instanceof Error ? error.message : "Failed to stop scheduler";
|
|
1699
|
+
return { success: false, error: message };
|
|
1700
|
+
}
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
// Lookup single ID
|
|
1704
|
+
ipcMain.handle(
|
|
1705
|
+
"dictionary:lookup",
|
|
1706
|
+
async (...[, lookupId]: [Electron.IpcMainInvokeEvent, string]) => {
|
|
1707
|
+
try {
|
|
1708
|
+
const lookupService = getLocalDictionaryLookupService();
|
|
1709
|
+
const result = lookupService.lookup(lookupId);
|
|
1710
|
+
return { success: true, result };
|
|
1711
|
+
} catch (error) {
|
|
1712
|
+
log.error("[Dictionary] Lookup failed:", error);
|
|
1713
|
+
const message = error instanceof Error ? error.message : "Failed to lookup";
|
|
1714
|
+
return { success: false, error: message };
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
);
|
|
1718
|
+
|
|
1719
|
+
// Batch lookup multiple IDs
|
|
1720
|
+
ipcMain.handle(
|
|
1721
|
+
"dictionary:batch-lookup",
|
|
1722
|
+
async (...[, lookupIds]: [Electron.IpcMainInvokeEvent, string[]]) => {
|
|
1723
|
+
try {
|
|
1724
|
+
const lookupService = getLocalDictionaryLookupService();
|
|
1725
|
+
const results = lookupService.batchLookup(lookupIds);
|
|
1726
|
+
return { success: true, results };
|
|
1727
|
+
} catch (error) {
|
|
1728
|
+
log.error("[Dictionary] Batch lookup failed:", error);
|
|
1729
|
+
const message = error instanceof Error ? error.message : "Failed to batch lookup";
|
|
1730
|
+
return { success: false, error: message, results: [] };
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
);
|
|
1734
|
+
|
|
1735
|
+
// Get sync status
|
|
1736
|
+
ipcMain.handle("dictionary:get-status", async () => {
|
|
1737
|
+
try {
|
|
1738
|
+
const dictionaryService = getDictionaryService();
|
|
1739
|
+
const status = dictionaryService.getSyncStatus();
|
|
1740
|
+
return { success: true, status };
|
|
1741
|
+
} catch (error) {
|
|
1742
|
+
log.error("[Dictionary] Get status failed:", error);
|
|
1743
|
+
const message = error instanceof Error ? error.message : "Failed to get status";
|
|
1744
|
+
return { success: false, error: message };
|
|
1745
|
+
}
|
|
1746
|
+
});
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
// ==============================================================================
|
|
1750
|
+
// Auto-Updater Configuration
|
|
1751
|
+
// ==============================================================================
|
|
1752
|
+
|
|
1753
|
+
class AutoUpdaterHandler {
|
|
1754
|
+
private customUpdater: CustomUpdater;
|
|
1755
|
+
private updateCheckInProgress = false;
|
|
1756
|
+
private downloadInProgress = false;
|
|
1757
|
+
|
|
1758
|
+
constructor() {
|
|
1759
|
+
this.customUpdater = new CustomUpdater(mainWindow);
|
|
1760
|
+
this.setupIPCHandlers();
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
private setupIPCHandlers(): void {
|
|
1764
|
+
// Check for updates
|
|
1765
|
+
ipcMain.handle("check-for-updates", async () => {
|
|
1766
|
+
if (this.updateCheckInProgress) {
|
|
1767
|
+
return {
|
|
1768
|
+
success: false,
|
|
1769
|
+
message: "Update check already in progress",
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
try {
|
|
1774
|
+
this.updateCheckInProgress = true;
|
|
1775
|
+
const result = await this.customUpdater.checkForUpdates();
|
|
1776
|
+
this.updateCheckInProgress = false;
|
|
1777
|
+
return result;
|
|
1778
|
+
} catch (error) {
|
|
1779
|
+
this.updateCheckInProgress = false;
|
|
1780
|
+
return {
|
|
1781
|
+
success: false,
|
|
1782
|
+
message: error instanceof Error ? error.message : "Failed to check for updates",
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
});
|
|
1786
|
+
|
|
1787
|
+
// Download update
|
|
1788
|
+
ipcMain.handle("download-update", async () => {
|
|
1789
|
+
if (this.downloadInProgress) {
|
|
1790
|
+
return {
|
|
1791
|
+
success: false,
|
|
1792
|
+
message: "Download already in progress",
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
try {
|
|
1797
|
+
this.downloadInProgress = true;
|
|
1798
|
+
const result = await this.customUpdater.downloadUpdate();
|
|
1799
|
+
this.downloadInProgress = false;
|
|
1800
|
+
return result;
|
|
1801
|
+
} catch (error) {
|
|
1802
|
+
this.downloadInProgress = false;
|
|
1803
|
+
return {
|
|
1804
|
+
success: false,
|
|
1805
|
+
message: error instanceof Error ? error.message : "Failed to download update",
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
// Install update and restart
|
|
1811
|
+
ipcMain.handle("install-update", () => {
|
|
1812
|
+
this.customUpdater.quitAndInstall();
|
|
1813
|
+
});
|
|
1814
|
+
|
|
1815
|
+
// SharePoint Update Source IPC Handlers
|
|
1816
|
+
ipcMain.handle("update:set-provider", async (_event, config: { type: 'github' | 'sharepoint'; sharePointUrl?: string }) => {
|
|
1817
|
+
return await this.customUpdater.setProvider(config);
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1820
|
+
ipcMain.handle("update:test-sharepoint-connection", async (_event, url: string) => {
|
|
1821
|
+
return await this.customUpdater.testSharePointConnection(url);
|
|
1822
|
+
});
|
|
1823
|
+
|
|
1824
|
+
ipcMain.handle("update:sharepoint-login", async () => {
|
|
1825
|
+
return await this.customUpdater.sharePointLogin();
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
ipcMain.handle("update:sharepoint-logout", async () => {
|
|
1829
|
+
await this.customUpdater.sharePointLogout();
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
// Dictionary: Interactive SharePoint retrieval using browser authentication
|
|
1833
|
+
ipcMain.handle(
|
|
1834
|
+
"dictionary:retrieve-from-sharepoint",
|
|
1835
|
+
async (...[, { fileUrl }]: [Electron.IpcMainInvokeEvent, { fileUrl: string }]) => {
|
|
1836
|
+
try {
|
|
1837
|
+
log.info("[Dictionary] Starting interactive SharePoint retrieval", { fileUrl });
|
|
1838
|
+
|
|
1839
|
+
// Step 1: Download file using interactive auth
|
|
1840
|
+
const downloadResult = await this.customUpdater.downloadSharePointFile(fileUrl);
|
|
1841
|
+
if (!downloadResult.success || !downloadResult.data) {
|
|
1842
|
+
return {
|
|
1843
|
+
success: false,
|
|
1844
|
+
error: downloadResult.error || "Failed to download file",
|
|
1845
|
+
entriesImported: 0,
|
|
1846
|
+
};
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
// Step 2: Parse Excel file
|
|
1850
|
+
const syncService = getSharePointSyncService();
|
|
1851
|
+
const entries = syncService.parseExcelFile(downloadResult.data);
|
|
1852
|
+
|
|
1853
|
+
// Step 3: Import to SQLite database
|
|
1854
|
+
const dictionaryService = getDictionaryService();
|
|
1855
|
+
await dictionaryService.importEntries(entries, (progress) => {
|
|
1856
|
+
// Send progress updates to renderer
|
|
1857
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
1858
|
+
mainWindow.webContents.send("dictionary:sync-progress", {
|
|
1859
|
+
phase: "importing",
|
|
1860
|
+
progress: progress.percentage,
|
|
1861
|
+
message: `Importing ${progress.current} of ${progress.total} entries`,
|
|
1862
|
+
entriesProcessed: progress.current,
|
|
1863
|
+
totalEntries: progress.total,
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
});
|
|
1867
|
+
|
|
1868
|
+
// Step 4: Update sync status
|
|
1869
|
+
dictionaryService.updateSyncStatus({
|
|
1870
|
+
lastSyncTime: new Date().toISOString(),
|
|
1871
|
+
lastSyncSuccess: true,
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
log.info("[Dictionary] Interactive retrieval completed", {
|
|
1875
|
+
entriesImported: entries.length,
|
|
1876
|
+
});
|
|
1877
|
+
|
|
1878
|
+
return {
|
|
1879
|
+
success: true,
|
|
1880
|
+
entriesImported: entries.length,
|
|
1881
|
+
};
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
log.error("[Dictionary] Interactive retrieval failed:", error);
|
|
1884
|
+
const message =
|
|
1885
|
+
error instanceof Error ? error.message : "Failed to retrieve dictionary";
|
|
1886
|
+
return { success: false, error: message, entriesImported: 0 };
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
);
|
|
1890
|
+
|
|
1891
|
+
// Dictionary: SharePoint login (reuses update auth)
|
|
1892
|
+
ipcMain.handle("dictionary:sharepoint-login", async () => {
|
|
1893
|
+
return await this.customUpdater.sharePointLogin();
|
|
1894
|
+
});
|
|
1895
|
+
|
|
1896
|
+
// Dictionary: Check SharePoint authentication status
|
|
1897
|
+
ipcMain.handle("dictionary:is-sharepoint-authenticated", () => {
|
|
1898
|
+
return { authenticated: this.customUpdater.isSharePointAuthenticated() };
|
|
1899
|
+
});
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// Check for updates on app start (if enabled in settings)
|
|
1903
|
+
public async checkOnStartup(): Promise<void> {
|
|
1904
|
+
await this.customUpdater.checkOnStartup();
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// Start scheduled periodic update checks
|
|
1908
|
+
public startScheduledChecks(intervalMs?: number): void {
|
|
1909
|
+
this.customUpdater.startScheduledChecks(intervalMs);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// ============================================================================
|
|
1914
|
+
// Display/Monitor IPC Handlers
|
|
1915
|
+
// ============================================================================
|
|
1916
|
+
|
|
1917
|
+
// Track active identification windows and timeout for cleanup
|
|
1918
|
+
let identifyWindows: BrowserWindow[] = [];
|
|
1919
|
+
let identifyCloseTimeout: NodeJS.Timeout | null = null;
|
|
1920
|
+
|
|
1921
|
+
// Get all displays with their information
|
|
1922
|
+
ipcMain.handle("display:get-all-displays", () => {
|
|
1923
|
+
const displays = screen.getAllDisplays();
|
|
1924
|
+
const primaryDisplay = screen.getPrimaryDisplay();
|
|
1925
|
+
|
|
1926
|
+
return displays.map((d, index) => ({
|
|
1927
|
+
id: index,
|
|
1928
|
+
label: d.id === primaryDisplay.id ? "Primary" : `Display ${index + 1}`,
|
|
1929
|
+
bounds: d.bounds,
|
|
1930
|
+
workArea: d.workArea,
|
|
1931
|
+
scaleFactor: d.scaleFactor,
|
|
1932
|
+
isPrimary: d.id === primaryDisplay.id,
|
|
1933
|
+
}));
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
// Show identification overlay on all monitors for 3 seconds
|
|
1937
|
+
ipcMain.handle("display:identify-monitors", async () => {
|
|
1938
|
+
// Cancel any pending timeout from previous identification
|
|
1939
|
+
if (identifyCloseTimeout) {
|
|
1940
|
+
clearTimeout(identifyCloseTimeout);
|
|
1941
|
+
identifyCloseTimeout = null;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// Close any existing identification windows
|
|
1945
|
+
identifyWindows.forEach((win) => {
|
|
1946
|
+
if (!win.isDestroyed()) {
|
|
1947
|
+
win.close();
|
|
1948
|
+
}
|
|
1949
|
+
});
|
|
1950
|
+
identifyWindows = [];
|
|
1951
|
+
|
|
1952
|
+
const displays = screen.getAllDisplays();
|
|
1953
|
+
const primaryDisplay = screen.getPrimaryDisplay();
|
|
1954
|
+
|
|
1955
|
+
displays.forEach((display, index) => {
|
|
1956
|
+
const isPrimary = display.id === primaryDisplay.id;
|
|
1957
|
+
const label = isPrimary ? "1 (Primary)" : `${index + 1}`;
|
|
1958
|
+
|
|
1959
|
+
// Create a small, centered identification window on each display
|
|
1960
|
+
const identifyWindow = new BrowserWindow({
|
|
1961
|
+
x: display.bounds.x + Math.floor(display.bounds.width / 2) - 150,
|
|
1962
|
+
y: display.bounds.y + Math.floor(display.bounds.height / 2) - 100,
|
|
1963
|
+
width: 300,
|
|
1964
|
+
height: 200,
|
|
1965
|
+
frame: false,
|
|
1966
|
+
transparent: true,
|
|
1967
|
+
alwaysOnTop: true,
|
|
1968
|
+
skipTaskbar: true,
|
|
1969
|
+
resizable: false,
|
|
1970
|
+
focusable: false,
|
|
1971
|
+
webPreferences: {
|
|
1972
|
+
nodeIntegration: false,
|
|
1973
|
+
contextIsolation: true,
|
|
1974
|
+
},
|
|
1975
|
+
});
|
|
1976
|
+
|
|
1977
|
+
// Create HTML content with the monitor number
|
|
1978
|
+
const htmlContent = `
|
|
1979
|
+
<!DOCTYPE html>
|
|
1980
|
+
<html>
|
|
1981
|
+
<head>
|
|
1982
|
+
<style>
|
|
1983
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
1984
|
+
body {
|
|
1985
|
+
display: flex;
|
|
1986
|
+
justify-content: center;
|
|
1987
|
+
align-items: center;
|
|
1988
|
+
height: 100vh;
|
|
1989
|
+
background: rgba(0, 0, 0, 0.85);
|
|
1990
|
+
border-radius: 20px;
|
|
1991
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1992
|
+
}
|
|
1993
|
+
.container {
|
|
1994
|
+
text-align: center;
|
|
1995
|
+
color: white;
|
|
1996
|
+
}
|
|
1997
|
+
.number {
|
|
1998
|
+
font-size: 80px;
|
|
1999
|
+
font-weight: bold;
|
|
2000
|
+
line-height: 1;
|
|
2001
|
+
}
|
|
2002
|
+
.label {
|
|
2003
|
+
font-size: 16px;
|
|
2004
|
+
opacity: 0.8;
|
|
2005
|
+
margin-top: 8px;
|
|
2006
|
+
}
|
|
2007
|
+
</style>
|
|
2008
|
+
</head>
|
|
2009
|
+
<body>
|
|
2010
|
+
<div class="container">
|
|
2011
|
+
<div class="number">${index + 1}</div>
|
|
2012
|
+
<div class="label">${isPrimary ? "Primary Monitor" : `Monitor ${index + 1}`}</div>
|
|
2013
|
+
</div>
|
|
2014
|
+
</body>
|
|
2015
|
+
</html>
|
|
2016
|
+
`;
|
|
2017
|
+
|
|
2018
|
+
identifyWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`);
|
|
2019
|
+
identifyWindows.push(identifyWindow);
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
// Close all identification windows after 3 seconds
|
|
2023
|
+
identifyCloseTimeout = setTimeout(() => {
|
|
2024
|
+
identifyWindows.forEach((win) => {
|
|
2025
|
+
if (!win.isDestroyed()) {
|
|
2026
|
+
win.close();
|
|
2027
|
+
}
|
|
2028
|
+
});
|
|
2029
|
+
identifyWindows = [];
|
|
2030
|
+
identifyCloseTimeout = null;
|
|
2031
|
+
}, 3000);
|
|
2032
|
+
|
|
2033
|
+
return { success: true };
|
|
2034
|
+
});
|
|
2035
|
+
|
|
2036
|
+
// Open comparison - two Word documents side by side on selected monitor
|
|
2037
|
+
ipcMain.handle(
|
|
2038
|
+
"display:open-comparison",
|
|
2039
|
+
async (
|
|
2040
|
+
_event,
|
|
2041
|
+
{ backupPath, processedPath, monitorIndex }: { backupPath: string; processedPath: string; monitorIndex: number }
|
|
2042
|
+
) => {
|
|
2043
|
+
try {
|
|
2044
|
+
// Validate files exist
|
|
2045
|
+
if (!fs.existsSync(backupPath)) {
|
|
2046
|
+
return { success: false, error: `Backup file not found: ${backupPath}` };
|
|
2047
|
+
}
|
|
2048
|
+
if (!fs.existsSync(processedPath)) {
|
|
2049
|
+
return { success: false, error: `Processed file not found: ${processedPath}` };
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
// Get the target display
|
|
2053
|
+
const displays = screen.getAllDisplays();
|
|
2054
|
+
const targetDisplay = displays[monitorIndex] || displays[0];
|
|
2055
|
+
const { x, y, width, height } = targetDisplay.workArea;
|
|
2056
|
+
|
|
2057
|
+
log.info("[Display] Opening comparison", {
|
|
2058
|
+
backupPath,
|
|
2059
|
+
processedPath,
|
|
2060
|
+
monitor: monitorIndex,
|
|
2061
|
+
workArea: { x, y, width, height },
|
|
2062
|
+
scaleFactor: targetDisplay.scaleFactor,
|
|
2063
|
+
});
|
|
2064
|
+
|
|
2065
|
+
// Open both documents - they will open in Word
|
|
2066
|
+
// On Windows, we can use PowerShell to position the windows after opening
|
|
2067
|
+
const backupError = await shell.openPath(backupPath);
|
|
2068
|
+
if (backupError) {
|
|
2069
|
+
log.error("[Display] Failed to open backup:", backupError);
|
|
2070
|
+
return { success: false, error: `Failed to open backup: ${backupError}` };
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// Small delay before opening second file to avoid conflicts
|
|
2074
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
2075
|
+
|
|
2076
|
+
const processedError = await shell.openPath(processedPath);
|
|
2077
|
+
if (processedError) {
|
|
2078
|
+
log.error("[Display] Failed to open processed file:", processedError);
|
|
2079
|
+
return { success: false, error: `Failed to open processed file: ${processedError}` };
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// On Windows, try to position the Word windows using PowerShell
|
|
2083
|
+
if (process.platform === "win32") {
|
|
2084
|
+
// Brief delay before launching positioning script (retry loop handles Word startup)
|
|
2085
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
2086
|
+
|
|
2087
|
+
// PowerShell script to position Word windows
|
|
2088
|
+
const { exec } = await import("child_process");
|
|
2089
|
+
const util = await import("util");
|
|
2090
|
+
const execPromise = util.promisify(exec);
|
|
2091
|
+
|
|
2092
|
+
// Extract filenames to match against window titles
|
|
2093
|
+
// Word titles include filename: "MyDoc.docx - Word"
|
|
2094
|
+
const backupFilename = path.basename(backupPath);
|
|
2095
|
+
const processedFilename = path.basename(processedPath);
|
|
2096
|
+
|
|
2097
|
+
log.info("[Display] Looking for Word windows with filenames:", {
|
|
2098
|
+
backup: backupFilename,
|
|
2099
|
+
processed: processedFilename,
|
|
2100
|
+
});
|
|
2101
|
+
|
|
2102
|
+
// PowerShell script to find and position Word windows BY FILENAME
|
|
2103
|
+
// Uses native .NET monitor enumeration to get coordinates in the same
|
|
2104
|
+
// coordinate system as SetWindowPos — eliminates DIP-to-physical conversion
|
|
2105
|
+
// issues on multi-monitor setups with different scale factors
|
|
2106
|
+
const psScript = `
|
|
2107
|
+
Add-Type @"
|
|
2108
|
+
using System;
|
|
2109
|
+
using System.Runtime.InteropServices;
|
|
2110
|
+
public class DpiHelper {
|
|
2111
|
+
[DllImport("user32.dll")]
|
|
2112
|
+
public static extern bool SetProcessDPIAware();
|
|
2113
|
+
[DllImport("user32.dll", EntryPoint = "SetProcessDpiAwarenessContext")]
|
|
2114
|
+
static extern int SetDpiAwarenessCtx(IntPtr value);
|
|
2115
|
+
public static void SetBestDpiAwareness() {
|
|
2116
|
+
try {
|
|
2117
|
+
// Per-Monitor DPI Aware V2 (Windows 10 1703+)
|
|
2118
|
+
SetDpiAwarenessCtx(new IntPtr(-4));
|
|
2119
|
+
} catch {
|
|
2120
|
+
// Fallback to system-level DPI awareness on older Windows
|
|
2121
|
+
SetProcessDPIAware();
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
"@
|
|
2126
|
+
[DpiHelper]::SetBestDpiAwareness()
|
|
2127
|
+
|
|
2128
|
+
Add-Type @"
|
|
2129
|
+
using System;
|
|
2130
|
+
using System.Runtime.InteropServices;
|
|
2131
|
+
public class Win32 {
|
|
2132
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
2133
|
+
public struct POINT { public int X; public int Y; }
|
|
2134
|
+
[DllImport("user32.dll")]
|
|
2135
|
+
public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
|
|
2136
|
+
[DllImport("user32.dll")]
|
|
2137
|
+
public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
|
2138
|
+
[DllImport("user32.dll")]
|
|
2139
|
+
public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder lpString, int nMaxCount);
|
|
2140
|
+
[DllImport("user32.dll")]
|
|
2141
|
+
public static extern bool IsWindowVisible(IntPtr hWnd);
|
|
2142
|
+
[DllImport("user32.dll")]
|
|
2143
|
+
public static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags);
|
|
2144
|
+
[DllImport("shcore.dll")]
|
|
2145
|
+
public static extern int GetDpiForMonitor(IntPtr hMonitor, int dpiType, out uint dpiX, out uint dpiY);
|
|
2146
|
+
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
|
2147
|
+
public static IntPtr FindWindowByTitle(string filename) {
|
|
2148
|
+
IntPtr found = IntPtr.Zero;
|
|
2149
|
+
EnumWindows(delegate(IntPtr hWnd, IntPtr lParam) {
|
|
2150
|
+
if (!IsWindowVisible(hWnd)) return true;
|
|
2151
|
+
var sb = new System.Text.StringBuilder(512);
|
|
2152
|
+
GetWindowText(hWnd, sb, 512);
|
|
2153
|
+
string title = sb.ToString();
|
|
2154
|
+
if (title.IndexOf("Word", StringComparison.OrdinalIgnoreCase) >= 0 &&
|
|
2155
|
+
title.IndexOf(filename, StringComparison.OrdinalIgnoreCase) >= 0) {
|
|
2156
|
+
found = hWnd;
|
|
2157
|
+
return false;
|
|
2158
|
+
}
|
|
2159
|
+
return true;
|
|
2160
|
+
}, IntPtr.Zero);
|
|
2161
|
+
return found;
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
"@
|
|
2165
|
+
|
|
2166
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
2167
|
+
|
|
2168
|
+
# Get target monitor natively — coordinates match SetWindowPos coordinate system
|
|
2169
|
+
$monitorIndex = [int]$env:DOCHUB_MONITOR_INDEX
|
|
2170
|
+
$screens = [System.Windows.Forms.Screen]::AllScreens
|
|
2171
|
+
if ($monitorIndex -lt 0 -or $monitorIndex -ge $screens.Length) { $monitorIndex = 0 }
|
|
2172
|
+
$targetScreen = $screens[$monitorIndex]
|
|
2173
|
+
$wa = $targetScreen.WorkingArea
|
|
2174
|
+
|
|
2175
|
+
$x = $wa.X
|
|
2176
|
+
$y = $wa.Y
|
|
2177
|
+
$width = $wa.Width
|
|
2178
|
+
$height = $wa.Height
|
|
2179
|
+
|
|
2180
|
+
# Get the target monitor's DPI to scale sizing constants from logical to physical
|
|
2181
|
+
$pt = New-Object Win32+POINT
|
|
2182
|
+
$pt.X = $wa.X + 1
|
|
2183
|
+
$pt.Y = $wa.Y + 1
|
|
2184
|
+
$hMonitor = [Win32]::MonitorFromPoint($pt, 2)
|
|
2185
|
+
$dpiX = [uint32]0
|
|
2186
|
+
$dpiY = [uint32]0
|
|
2187
|
+
[Win32]::GetDpiForMonitor($hMonitor, 0, [ref]$dpiX, [ref]$dpiY) | Out-Null
|
|
2188
|
+
$scaleFactor = if ($dpiX -gt 0) { $dpiX / 96.0 } else { 1.0 }
|
|
2189
|
+
|
|
2190
|
+
# Scale sizing constants from logical (96 DPI) values to physical pixels
|
|
2191
|
+
$halfWidth = [Math]::Floor($width / 2)
|
|
2192
|
+
$optimalWidth = [Math]::Round(960 * $scaleFactor)
|
|
2193
|
+
$maxWindowWidth = [Math]::Round(1200 * $scaleFactor)
|
|
2194
|
+
$minWindowWidth = [Math]::Round(700 * $scaleFactor)
|
|
2195
|
+
|
|
2196
|
+
if ($halfWidth -lt $minWindowWidth) {
|
|
2197
|
+
$windowWidth = $halfWidth
|
|
2198
|
+
} elseif ($halfWidth -gt $maxWindowWidth) {
|
|
2199
|
+
$windowWidth = $maxWindowWidth
|
|
2200
|
+
} else {
|
|
2201
|
+
$windowWidth = [Math]::Min($optimalWidth, $halfWidth)
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
# Center the two windows on the display
|
|
2205
|
+
$totalWidth = $windowWidth * 2
|
|
2206
|
+
$startX = $x + [Math]::Floor(($width - $totalWidth) / 2)
|
|
2207
|
+
$leftX = $startX
|
|
2208
|
+
$rightX = $startX + $windowWidth
|
|
2209
|
+
|
|
2210
|
+
$backupFilename = $env:DOCHUB_BACKUP_FILENAME
|
|
2211
|
+
$processedFilename = $env:DOCHUB_PROCESSED_FILENAME
|
|
2212
|
+
|
|
2213
|
+
$backupHwnd = [IntPtr]::Zero
|
|
2214
|
+
$processedHwnd = [IntPtr]::Zero
|
|
2215
|
+
|
|
2216
|
+
for ($i = 0; $i -lt 8; $i++) {
|
|
2217
|
+
if ($backupHwnd -eq [IntPtr]::Zero) {
|
|
2218
|
+
$backupHwnd = [Win32]::FindWindowByTitle($backupFilename)
|
|
2219
|
+
}
|
|
2220
|
+
if ($processedHwnd -eq [IntPtr]::Zero) {
|
|
2221
|
+
$processedHwnd = [Win32]::FindWindowByTitle($processedFilename)
|
|
2222
|
+
}
|
|
2223
|
+
if ($backupHwnd -ne [IntPtr]::Zero -and $processedHwnd -ne [IntPtr]::Zero) {
|
|
2224
|
+
break
|
|
2225
|
+
}
|
|
2226
|
+
Start-Sleep -Milliseconds 750
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
if ($backupHwnd -ne [IntPtr]::Zero) {
|
|
2230
|
+
[Win32]::SetWindowPos($backupHwnd, [IntPtr]::Zero, $leftX, $y, $windowWidth, $height, 0x0040) | Out-Null
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
if ($processedHwnd -ne [IntPtr]::Zero) {
|
|
2234
|
+
[Win32]::SetWindowPos($processedHwnd, [IntPtr]::Zero, $rightX, $y, $windowWidth, $height, 0x0040) | Out-Null
|
|
2235
|
+
}
|
|
2236
|
+
`;
|
|
2237
|
+
|
|
2238
|
+
try {
|
|
2239
|
+
// Encode script as Base64 (UTF-16LE) for PowerShell -EncodedCommand
|
|
2240
|
+
const scriptBuffer = Buffer.from(psScript, "utf16le");
|
|
2241
|
+
const encodedScript = scriptBuffer.toString("base64");
|
|
2242
|
+
|
|
2243
|
+
const psResult = await execPromise(`powershell -EncodedCommand ${encodedScript}`, {
|
|
2244
|
+
windowsHide: true,
|
|
2245
|
+
env: {
|
|
2246
|
+
...process.env,
|
|
2247
|
+
DOCHUB_MONITOR_INDEX: monitorIndex.toString(),
|
|
2248
|
+
DOCHUB_BACKUP_FILENAME: backupFilename,
|
|
2249
|
+
DOCHUB_PROCESSED_FILENAME: processedFilename,
|
|
2250
|
+
},
|
|
2251
|
+
});
|
|
2252
|
+
if (psResult.stderr) {
|
|
2253
|
+
log.warn("[Display] PowerShell warnings:", psResult.stderr);
|
|
2254
|
+
}
|
|
2255
|
+
log.info("[Display] Word windows positioned successfully (backup=left, processed=right)");
|
|
2256
|
+
} catch (psError) {
|
|
2257
|
+
// Non-fatal - windows opened but positioning may have failed
|
|
2258
|
+
log.warn("[Display] Could not auto-position Word windows:", psError);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
return { success: true };
|
|
2263
|
+
} catch (error) {
|
|
2264
|
+
log.error("[Display] Error opening comparison:", error);
|
|
2265
|
+
const message = error instanceof Error ? error.message : "Failed to open comparison";
|
|
2266
|
+
return { success: false, error: message };
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
);
|
|
2270
|
+
|
|
2271
|
+
// ============================================================================
|
|
2272
|
+
// Export & Reporting Handlers
|
|
2273
|
+
// ============================================================================
|
|
2274
|
+
|
|
2275
|
+
// Select folder for export
|
|
2276
|
+
ipcMain.handle("select-folder", async () => {
|
|
2277
|
+
if (!mainWindow) {
|
|
2278
|
+
throw new Error("Main window not available");
|
|
2279
|
+
}
|
|
2280
|
+
const result = await dialog.showOpenDialog(mainWindow, {
|
|
2281
|
+
properties: ["openDirectory"],
|
|
2282
|
+
title: "Select Export Folder",
|
|
2283
|
+
});
|
|
2284
|
+
return result.canceled ? null : result.filePaths[0];
|
|
2285
|
+
});
|
|
2286
|
+
|
|
2287
|
+
// Copy files to folder
|
|
2288
|
+
ipcMain.handle(
|
|
2289
|
+
"copy-files-to-folder",
|
|
2290
|
+
async (
|
|
2291
|
+
...[, request]: [
|
|
2292
|
+
Electron.IpcMainInvokeEvent,
|
|
2293
|
+
{ filePaths: string[]; destinationFolder: string }
|
|
2294
|
+
]
|
|
2295
|
+
) => {
|
|
2296
|
+
const { filePaths, destinationFolder } = request;
|
|
2297
|
+
let copied = 0;
|
|
2298
|
+
let skipped = 0;
|
|
2299
|
+
|
|
2300
|
+
const validatedDest = validateIpcPath(destinationFolder);
|
|
2301
|
+
for (const sourcePath of filePaths) {
|
|
2302
|
+
try {
|
|
2303
|
+
const validatedSource = validateIpcPath(sourcePath, { requireExists: true, mustBeFile: true });
|
|
2304
|
+
const fileName = path.basename(validatedSource);
|
|
2305
|
+
const destPath = path.join(validatedDest, fileName);
|
|
2306
|
+
await fsPromises.copyFile(validatedSource, destPath);
|
|
2307
|
+
copied++;
|
|
2308
|
+
} catch (error) {
|
|
2309
|
+
log.warn(`Failed to copy file ${sourcePath}:`, error);
|
|
2310
|
+
skipped++;
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
log.info(`Export complete: ${copied} files copied, ${skipped} skipped`);
|
|
2315
|
+
return { copied, skipped };
|
|
2316
|
+
}
|
|
2317
|
+
);
|
|
2318
|
+
|
|
2319
|
+
// Get downloads path
|
|
2320
|
+
ipcMain.handle("get-downloads-path", () => {
|
|
2321
|
+
return app.getPath("downloads");
|
|
2322
|
+
});
|
|
2323
|
+
|
|
2324
|
+
// Create folder
|
|
2325
|
+
ipcMain.handle(
|
|
2326
|
+
"create-folder",
|
|
2327
|
+
async (...[, folderPath]: [Electron.IpcMainInvokeEvent, string]) => {
|
|
2328
|
+
const validatedPath = validateIpcPath(folderPath);
|
|
2329
|
+
await fsPromises.mkdir(validatedPath, { recursive: true });
|
|
2330
|
+
return true;
|
|
2331
|
+
}
|
|
2332
|
+
);
|
|
2333
|
+
|
|
2334
|
+
// Copy single file to folder
|
|
2335
|
+
ipcMain.handle(
|
|
2336
|
+
"copy-file-to-folder",
|
|
2337
|
+
async (
|
|
2338
|
+
...[, request]: [
|
|
2339
|
+
Electron.IpcMainInvokeEvent,
|
|
2340
|
+
{ sourcePath: string; destFolder: string }
|
|
2341
|
+
]
|
|
2342
|
+
) => {
|
|
2343
|
+
const { sourcePath, destFolder } = request;
|
|
2344
|
+
const validatedSource = validateIpcPath(sourcePath, { requireExists: true, mustBeFile: true });
|
|
2345
|
+
const validatedDest = validateIpcPath(destFolder);
|
|
2346
|
+
const fileName = path.basename(validatedSource);
|
|
2347
|
+
const destPath = path.join(validatedDest, fileName);
|
|
2348
|
+
await fsPromises.copyFile(validatedSource, destPath);
|
|
2349
|
+
return true;
|
|
2350
|
+
}
|
|
2351
|
+
);
|
|
2352
|
+
|
|
2353
|
+
// Create report zip
|
|
2354
|
+
ipcMain.handle(
|
|
2355
|
+
"create-report-zip",
|
|
2356
|
+
async (
|
|
2357
|
+
...[, request]: [
|
|
2358
|
+
Electron.IpcMainInvokeEvent,
|
|
2359
|
+
{ folderPath: string; zipName: string }
|
|
2360
|
+
]
|
|
2361
|
+
) => {
|
|
2362
|
+
const { folderPath, zipName } = request;
|
|
2363
|
+
const validatedFolder = validateIpcPath(folderPath);
|
|
2364
|
+
const AdmZip = require("adm-zip");
|
|
2365
|
+
const zip = new AdmZip();
|
|
2366
|
+
zip.addLocalFolder(validatedFolder);
|
|
2367
|
+
const zipPath = path.join(app.getPath("downloads"), zipName);
|
|
2368
|
+
zip.writeZip(zipPath);
|
|
2369
|
+
// Clean up the folder after zipping
|
|
2370
|
+
await fsPromises.rm(validatedFolder, { recursive: true, force: true });
|
|
2371
|
+
log.info(`Created report zip: ${zipPath}`);
|
|
2372
|
+
return zipPath;
|
|
2373
|
+
}
|
|
2374
|
+
);
|
|
2375
|
+
|
|
2376
|
+
// Open email client with attachment (Classic Outlook COM → mailto fallback)
|
|
2377
|
+
ipcMain.handle(
|
|
2378
|
+
"open-outlook-email",
|
|
2379
|
+
async (
|
|
2380
|
+
...[, request]: [
|
|
2381
|
+
Electron.IpcMainInvokeEvent,
|
|
2382
|
+
{ subject: string; attachmentPath: string }
|
|
2383
|
+
]
|
|
2384
|
+
) => {
|
|
2385
|
+
const { subject, attachmentPath } = request;
|
|
2386
|
+
|
|
2387
|
+
// Phase 1: Try Classic Outlook via COM automation
|
|
2388
|
+
const psScript = `
|
|
2389
|
+
$outlook = New-Object -ComObject Outlook.Application
|
|
2390
|
+
$mail = $outlook.CreateItem(0)
|
|
2391
|
+
$mail.Subject = $env:DOCHUB_EMAIL_SUBJECT
|
|
2392
|
+
if ($env:DOCHUB_ATTACHMENT_PATH) {
|
|
2393
|
+
$mail.Attachments.Add($env:DOCHUB_ATTACHMENT_PATH)
|
|
2394
|
+
}
|
|
2395
|
+
$mail.Display()
|
|
2396
|
+
`;
|
|
2397
|
+
|
|
2398
|
+
const { exec } = require("child_process");
|
|
2399
|
+
const util = require("util");
|
|
2400
|
+
const execPromise = util.promisify(exec);
|
|
2401
|
+
|
|
2402
|
+
try {
|
|
2403
|
+
const scriptBuffer = Buffer.from(psScript, "utf16le");
|
|
2404
|
+
const encodedScript = scriptBuffer.toString("base64");
|
|
2405
|
+
await execPromise(`powershell -EncodedCommand ${encodedScript}`, {
|
|
2406
|
+
windowsHide: true,
|
|
2407
|
+
env: {
|
|
2408
|
+
...process.env,
|
|
2409
|
+
DOCHUB_EMAIL_SUBJECT: subject,
|
|
2410
|
+
DOCHUB_ATTACHMENT_PATH: attachmentPath,
|
|
2411
|
+
},
|
|
2412
|
+
});
|
|
2413
|
+
log.info(`Opened Outlook with attachment: ${attachmentPath}`);
|
|
2414
|
+
return { success: true, method: "outlook" as const };
|
|
2415
|
+
} catch (comError) {
|
|
2416
|
+
log.warn("Classic Outlook COM not available, falling back to mailto:", comError);
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
// Phase 2: Fallback to mailto + Explorer
|
|
2420
|
+
try {
|
|
2421
|
+
await shell.openExternal(`mailto:?subject=${encodeURIComponent(subject)}`);
|
|
2422
|
+
|
|
2423
|
+
if (attachmentPath && fs.existsSync(attachmentPath)) {
|
|
2424
|
+
shell.showItemInFolder(attachmentPath);
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
log.info(`Opened mailto fallback for subject: ${subject}`);
|
|
2428
|
+
return { success: true, method: "mailto" as const };
|
|
2429
|
+
} catch (mailtoError) {
|
|
2430
|
+
log.error("Failed to open email client:", mailtoError);
|
|
2431
|
+
throw mailtoError;
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
);
|
|
2435
|
+
|
|
2436
|
+
// ============================================================================
|
|
2437
|
+
// Auto-Updater Handler
|
|
2438
|
+
// ============================================================================
|
|
2439
|
+
// MOVED to consolidated initialization above (Step 4)
|
|
2440
|
+
// Now initialized AFTER certificate check completes (Issue #7 fix)
|
|
2441
|
+
let updaterHandler: AutoUpdaterHandler;
|