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.
Files changed (271) hide show
  1. package/.eslintrc.json +43 -0
  2. package/.github/workflows/build.yml +64 -0
  3. package/.github/workflows/ci.yml +39 -0
  4. package/.vscode/extensions.json +3 -0
  5. package/Current.md +97 -0
  6. package/DocHub_Image.png +0 -0
  7. package/README.md +666 -0
  8. package/USER_GUIDE.md +1173 -0
  9. package/Updater.md +311 -0
  10. package/build/256x256.png +0 -0
  11. package/build/512x512.png +0 -0
  12. package/build/app-update.yml +4 -0
  13. package/build/create-icon.js +208 -0
  14. package/build/icon.ico +0 -0
  15. package/build/icon.png +0 -0
  16. package/build/icon_1024x1024.png +0 -0
  17. package/dist/assets/Analytics-BpsG9895.js +1 -0
  18. package/dist/assets/Card-IAZin8kp.js +1 -0
  19. package/dist/assets/CurrentSession-B-rFkHvf.js +12 -0
  20. package/dist/assets/Dashboard-C_5gMb0q.js +1 -0
  21. package/dist/assets/Documents-CqZ25axS.js +1 -0
  22. package/dist/assets/Input-l89xwXBi.js +1 -0
  23. package/dist/assets/Reporting-DqdHJY_a.js +1 -0
  24. package/dist/assets/Search-XNbu5z_3.js +1 -0
  25. package/dist/assets/SessionManager-lH9hZfzH.js +1 -0
  26. package/dist/assets/Sessions-ClZOPYNc.js +1 -0
  27. package/dist/assets/Settings-DUEHGURa.js +11 -0
  28. package/dist/assets/index-8xUe8ptc.js +24 -0
  29. package/dist/assets/index-RYyJqF7O.css +1 -0
  30. package/dist/assets/path-BkOl0AGO.js +1 -0
  31. package/dist/assets/promises-ID_B9S-h.js +1 -0
  32. package/dist/assets/urlHelpers-TvgahX0r.js +1 -0
  33. package/dist/assets/useToast-yRSO1dkm.js +1 -0
  34. package/dist/assets/vendor-charts-RkGK5ROP.js +36 -0
  35. package/dist/assets/vendor-db-l0sNRNKZ.js +1 -0
  36. package/dist/assets/vendor-react-BVZ_anCF.js +4 -0
  37. package/dist/assets/vendor-search-Dw8P0qyA.js +1 -0
  38. package/dist/assets/vendor-ui-BU7NfluV.js +53 -0
  39. package/dist/electron/PowerAutomateApiService-LfW09ZGr.js +147 -0
  40. package/dist/electron/main-CXkNtyv-.js +19789 -0
  41. package/dist/electron/main.js +5 -0
  42. package/dist/electron/preload.js +1 -0
  43. package/dist/icon.png +0 -0
  44. package/dist/index.html +27 -0
  45. package/docs/CODEBASE_ANALYSIS_REPORT.md +309 -0
  46. package/docs/DEBUG_LOGGING_GUIDE.md +244 -0
  47. package/docs/README.md +115 -0
  48. package/docs/TOC_WIRING_GUIDE.md +344 -0
  49. package/docs/analysis/Bullet_Symbol_Bug_Analysis.md +136 -0
  50. package/docs/analysis/DOCXMLATER_ANALYSIS_SUMMARY.txt +169 -0
  51. package/docs/analysis/Document_Processing_Issues_Analysis.md +704 -0
  52. package/docs/analysis/FIELD_PRESERVATION_ANALYSIS.md +1200 -0
  53. package/docs/analysis/INDENTATION_PRESERVE_ANALYSIS.md +181 -0
  54. package/docs/analysis/INDENTATION_PRESERVE_IMPLEMENTATION.md +207 -0
  55. package/docs/analysis/List_Implementation.md +206 -0
  56. package/docs/analysis/List_Implementation_Accuracy_Report.md +366 -0
  57. package/docs/analysis/PROCESSING_OPTIONS_UI_UPDATES.md +220 -0
  58. package/docs/analysis/RefactorStyles.md +852 -0
  59. package/docs/analysis/STYLE_PARAMETER_ENHANCEMENT.md +143 -0
  60. package/docs/analysis/docxmlater-comparison-todo-2025-11-13.md +636 -0
  61. package/docs/analysis/docxmlater-implementation-analysis-2025-11-13.md +340 -0
  62. package/docs/analysis/docxmlater-template_ui-integration-analysis.md +263 -0
  63. package/docs/analysis/github-issues-to-create.md +237 -0
  64. package/docs/api/API_README.md +538 -0
  65. package/docs/api/API_REFERENCE.md +751 -0
  66. package/docs/api/TYPE_DEFINITIONS.md +869 -0
  67. package/docs/architecture/FONT_EMBEDDING_GUIDE.md +318 -0
  68. package/docs/architecture/docxmlater-functions-and-structure.md +726 -0
  69. package/docs/docxmlater-readme.md +1341 -0
  70. package/docs/fixes/EXECUTION_LOG_TEST_BASE.md +573 -0
  71. package/docs/fixes/HYPERLINK_TEXT_SANITIZATION.md +253 -0
  72. package/docs/fixes/README.md +37 -0
  73. package/docs/github-issues/issue-1-body.md +125 -0
  74. package/docs/github-issues/issue-10-body.md +850 -0
  75. package/docs/github-issues/issue-2-body.md +200 -0
  76. package/docs/github-issues/issue-3-body.md +270 -0
  77. package/docs/github-issues/issue-4-body.md +169 -0
  78. package/docs/github-issues/issue-5-body.md +173 -0
  79. package/docs/github-issues/issue-6-body.md +158 -0
  80. package/docs/github-issues/issue-7-body.md +171 -0
  81. package/docs/github-issues/issue-8-body.md +407 -0
  82. package/docs/github-issues/issue-9-body.md +515 -0
  83. package/docs/github-issues/issue-tracker.md +274 -0
  84. package/docs/github-issues/predictive-analysis-2025-10-18.md +2131 -0
  85. package/docs/implementation/List_Framework_Refactor_Plan.md +336 -0
  86. package/docs/implementation/PRIMARY_TEXT_COLOR_FEATURE.md +217 -0
  87. package/docs/implementation/RELEASE_PLAN_v2.1.0.md +362 -0
  88. package/docs/implementation/RefactorStyles.md +588 -0
  89. package/docs/implementation/implement-plan.md +489 -0
  90. package/docs/implementation/missing-helpers-implementation.md +391 -0
  91. package/docs/implementation/refactor-plan.md +520 -0
  92. package/docs/implementation/session-implementation-complete.md +233 -0
  93. package/docs/implementation/session-management-plan.md +250 -0
  94. package/docs/setup-checklist.md +77 -0
  95. package/docs/versions/changelog.md +345 -0
  96. package/electron/customUpdater.ts +656 -0
  97. package/electron/main.ts +2441 -0
  98. package/electron/memoryConfig.ts +187 -0
  99. package/electron/preload.ts +394 -0
  100. package/electron/proxyConfig.ts +340 -0
  101. package/electron/services/BackupService.ts +452 -0
  102. package/electron/services/DictionaryService.ts +402 -0
  103. package/electron/services/LocalDictionaryLookupService.ts +147 -0
  104. package/electron/services/PowerAutomateApiService.ts +231 -0
  105. package/electron/services/SharePointSyncService.ts +474 -0
  106. package/electron/windowsCertStore.ts +427 -0
  107. package/electron/zscalerConfig.ts +381 -0
  108. package/eslint.config.js +92 -0
  109. package/jest.config.js +52 -0
  110. package/package.json +214 -0
  111. package/postcss.config.mjs +6 -0
  112. package/public/icon.png +0 -0
  113. package/publish-release.ps1 +5 -0
  114. package/renovate.json +30 -0
  115. package/src/App.tsx +216 -0
  116. package/src/__mocks__/p-limit.js +12 -0
  117. package/src/__mocks__/styleMock.js +1 -0
  118. package/src/components/common/BugReportButton.tsx +44 -0
  119. package/src/components/common/BugReportDialog.tsx +193 -0
  120. package/src/components/common/Button.tsx +153 -0
  121. package/src/components/common/Card.tsx +86 -0
  122. package/src/components/common/ColorPickerDialog.tsx +177 -0
  123. package/src/components/common/ConfirmDialog.tsx +96 -0
  124. package/src/components/common/DebugConsole.tsx +275 -0
  125. package/src/components/common/EmptyState.tsx +183 -0
  126. package/src/components/common/ErrorBoundary.tsx +98 -0
  127. package/src/components/common/ErrorDetailsDialog.tsx +153 -0
  128. package/src/components/common/ErrorFallback.tsx +218 -0
  129. package/src/components/common/Input.tsx +109 -0
  130. package/src/components/common/Skeleton.tsx +184 -0
  131. package/src/components/common/SplashScreen.tsx +81 -0
  132. package/src/components/common/Toast.tsx +155 -0
  133. package/src/components/common/Tooltip.tsx +79 -0
  134. package/src/components/common/UpdateNotification.tsx +320 -0
  135. package/src/components/comparison/ComparisonWindow.tsx +374 -0
  136. package/src/components/comparison/SideBySideDiff.tsx +486 -0
  137. package/src/components/comparison/index.ts +8 -0
  138. package/src/components/document/DocumentUploader.tsx +288 -0
  139. package/src/components/document/HyperlinkPreview.tsx +430 -0
  140. package/src/components/document/HyperlinkService.md +1484 -0
  141. package/src/components/document/Hyperlink_Technical_Documentation.md +496 -0
  142. package/src/components/document/InlineChangesView.tsx +707 -0
  143. package/src/components/document/ProcessingProgress.tsx +303 -0
  144. package/src/components/document/ProcessingResults.tsx +256 -0
  145. package/src/components/document/TrackedChangesDetail.tsx +530 -0
  146. package/src/components/document/TrackedChangesPanel.tsx +546 -0
  147. package/src/components/document/VirtualDocumentList.tsx +240 -0
  148. package/src/components/editor/DocumentEditor.tsx +723 -0
  149. package/src/components/editor/DocumentEditorModal.tsx +640 -0
  150. package/src/components/editor/EditorQuickActions.tsx +502 -0
  151. package/src/components/editor/EditorToolbar.tsx +312 -0
  152. package/src/components/editor/TableEditor.tsx +926 -0
  153. package/src/components/editor/index.ts +18 -0
  154. package/src/components/layout/Header.tsx +190 -0
  155. package/src/components/layout/Sidebar.tsx +313 -0
  156. package/src/components/layout/TitleBar.tsx +190 -0
  157. package/src/components/navigation/CommandPalette.tsx +233 -0
  158. package/src/components/navigation/KeyboardShortcutsModal.tsx +173 -0
  159. package/src/components/sessions/ChangeItem.tsx +408 -0
  160. package/src/components/sessions/ChangeViewer.tsx +1155 -0
  161. package/src/components/sessions/DocumentComparisonModal.tsx +314 -0
  162. package/src/components/sessions/ProcessingOptions.tsx +297 -0
  163. package/src/components/sessions/ReplacementsTab.tsx +438 -0
  164. package/src/components/sessions/RevisionHandlingOptions.tsx +87 -0
  165. package/src/components/sessions/SessionManager.tsx +188 -0
  166. package/src/components/sessions/StylesEditor.tsx +1335 -0
  167. package/src/components/sessions/TabContainer.tsx +151 -0
  168. package/src/components/sessions/VirtualSessionList.tsx +157 -0
  169. package/src/components/sessions/sessionToProcessorManager.tsx +420 -0
  170. package/src/components/settings/CertificateManager.tsx +410 -0
  171. package/src/components/settings/SegmentedControl.tsx +88 -0
  172. package/src/components/settings/SettingRow.tsx +52 -0
  173. package/src/contexts/GlobalStatsContext.tsx +396 -0
  174. package/src/contexts/SessionContext.tsx +2129 -0
  175. package/src/contexts/ThemeContext.tsx +428 -0
  176. package/src/contexts/UserSettingsContext.tsx +290 -0
  177. package/src/contexts/__tests__/GlobalStatsContext.test.tsx +390 -0
  178. package/src/global.d.ts +273 -0
  179. package/src/hooks/useDocumentQueue.tsx +210 -0
  180. package/src/hooks/useToast.tsx +55 -0
  181. package/src/main.tsx +10 -0
  182. package/src/pages/Analytics.tsx +386 -0
  183. package/src/pages/CurrentSession.tsx +1174 -0
  184. package/src/pages/Dashboard.tsx +319 -0
  185. package/src/pages/Documents.tsx +317 -0
  186. package/src/pages/Projects.tsx +250 -0
  187. package/src/pages/Reporting.tsx +386 -0
  188. package/src/pages/Search.tsx +349 -0
  189. package/src/pages/Sessions.tsx +285 -0
  190. package/src/pages/Settings.tsx +2662 -0
  191. package/src/services/HyperlinkService.ts +1085 -0
  192. package/src/services/document/DocXMLaterProcessor.ts +617 -0
  193. package/src/services/document/DocumentProcessingComparison.ts +856 -0
  194. package/src/services/document/DocumentSnapshotService.ts +575 -0
  195. package/src/services/document/WordDocumentProcessor.ts +10509 -0
  196. package/src/services/document/__tests__/DocXMLaterProcessor.hyperlinks.test.md +311 -0
  197. package/src/services/document/__tests__/WordDocumentProcessor.integration.test.ts +515 -0
  198. package/src/services/document/__tests__/WordDocumentProcessor.test.ts +812 -0
  199. package/src/services/document/blanklines/BlankLineManager.ts +658 -0
  200. package/src/services/document/blanklines/__tests__/paragraphChecks.test.ts +281 -0
  201. package/src/services/document/blanklines/helpers/blankLineInsertion.ts +87 -0
  202. package/src/services/document/blanklines/helpers/blankLineSnapshot.ts +251 -0
  203. package/src/services/document/blanklines/helpers/clearCustom.ts +121 -0
  204. package/src/services/document/blanklines/helpers/contextChecks.ts +117 -0
  205. package/src/services/document/blanklines/helpers/imageChecks.ts +51 -0
  206. package/src/services/document/blanklines/helpers/paragraphChecks.ts +236 -0
  207. package/src/services/document/blanklines/helpers/removeBlanksBetweenListItems.ts +91 -0
  208. package/src/services/document/blanklines/helpers/removeTrailingBlanks.ts +35 -0
  209. package/src/services/document/blanklines/helpers/tableGuards.ts +21 -0
  210. package/src/services/document/blanklines/index.ts +67 -0
  211. package/src/services/document/blanklines/rules/additionRules.ts +337 -0
  212. package/src/services/document/blanklines/rules/indentationRules.ts +317 -0
  213. package/src/services/document/blanklines/rules/removalRules.ts +362 -0
  214. package/src/services/document/blanklines/rules/ruleTypes.ts +92 -0
  215. package/src/services/document/blanklines/types.ts +29 -0
  216. package/src/services/document/helpers/ImageBorderCropper.ts +377 -0
  217. package/src/services/document/helpers/__tests__/whitespace.test.ts +272 -0
  218. package/src/services/document/helpers/whitespace.ts +117 -0
  219. package/src/services/document/list/ListNormalizer.ts +947 -0
  220. package/src/services/document/list/index.ts +45 -0
  221. package/src/services/document/list/list-detection.ts +275 -0
  222. package/src/services/document/list/list-types.ts +162 -0
  223. package/src/services/document/processors/HyperlinkProcessor.ts +370 -0
  224. package/src/services/document/processors/ListProcessor.ts +257 -0
  225. package/src/services/document/processors/StructureProcessor.ts +176 -0
  226. package/src/services/document/processors/StyleProcessor.ts +389 -0
  227. package/src/services/document/processors/TableProcessor.ts +2238 -0
  228. package/src/services/document/processors/__tests__/HyperlinkProcessor.test.ts +314 -0
  229. package/src/services/document/processors/__tests__/ListProcessor.test.ts +291 -0
  230. package/src/services/document/processors/__tests__/StructureProcessor.test.ts +257 -0
  231. package/src/services/document/processors/__tests__/TableProcessor.hlp-tips-bullets.test.ts +459 -0
  232. package/src/services/document/processors/__tests__/TableProcessor.test.ts +1604 -0
  233. package/src/services/document/processors/index.ts +28 -0
  234. package/src/services/document/types/docx-processing.ts +310 -0
  235. package/src/services/editor/EditorActionHandlers.ts +901 -0
  236. package/src/services/editor/index.ts +13 -0
  237. package/src/setupTests.ts +47 -0
  238. package/src/styles/global.css +782 -0
  239. package/src/types/backup.ts +132 -0
  240. package/src/types/dictionary.ts +125 -0
  241. package/src/types/document-processing.ts +331 -0
  242. package/src/types/docxmlater-augments.d.ts +142 -0
  243. package/src/types/editor.ts +280 -0
  244. package/src/types/electron.ts +340 -0
  245. package/src/types/globalStats.ts +155 -0
  246. package/src/types/hyperlink.ts +471 -0
  247. package/src/types/operations.ts +354 -0
  248. package/src/types/session.ts +427 -0
  249. package/src/types/settings.ts +112 -0
  250. package/src/utils/MemoryMonitor.ts +248 -0
  251. package/src/utils/cn.ts +6 -0
  252. package/src/utils/colorConvert.ts +306 -0
  253. package/src/utils/diffUtils.ts +347 -0
  254. package/src/utils/documentUtils.ts +202 -0
  255. package/src/utils/electronGuard.ts +62 -0
  256. package/src/utils/indexedDB.ts +915 -0
  257. package/src/utils/logger.ts +717 -0
  258. package/src/utils/pathSecurity.ts +232 -0
  259. package/src/utils/pathValidator.ts +236 -0
  260. package/src/utils/processingTimeEstimator.ts +153 -0
  261. package/src/utils/safeJsonParse.ts +62 -0
  262. package/src/utils/textSanitizer.ts +162 -0
  263. package/src/utils/urlHelpers.ts +304 -0
  264. package/src/utils/urlPatterns.ts +198 -0
  265. package/src/utils/urlSanitizer.ts +152 -0
  266. package/src/vite-env.d.ts +11 -0
  267. package/tsconfig.electron.json +19 -0
  268. package/tsconfig.json +36 -0
  269. package/tsconfig.node.json +12 -0
  270. package/typedoc.json +45 -0
  271. package/vite.config.ts +152 -0
@@ -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;