Repository navigation
Expand file tree
/
Copy pathmain.js
More file actions
2855 lines (2558 loc) · 122 KB
/
Copy pathmain.js
File metadata and controls
2855 lines (2558 loc) · 122 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// main.js
//
// Electron main process. Responsibilities:
// 1. Create the app window (renderer has no Node/fs access - see preload.js).
// 2. Own the lifetime of a single long-running Python sidecar process that
// wraps pikepdf. We talk to it over stdin/stdout using newline-delimited
// JSON ("JSON lines"). Keeping it alive between calls avoids paying
// Python startup cost (and re-parsing the PDF) on every tag edit.
// 3. Expose a small set of IPC handlers that the preload script forwards
// to the renderer as `window.api.*`.
const { app, BrowserWindow, ipcMain, dialog, Menu, nativeTheme, safeStorage, shell } = require('electron');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { spawn } = require('child_process');
const readline = require('readline');
const { Anthropic } = require('@anthropic-ai/sdk');
const { zodOutputFormat } = require('@anthropic-ai/sdk/helpers/zod');
const { z } = require('zod');
const { autoUpdater } = require('electron-updater');
const changelog = require('./lib/changelog');
const { createAgentServer, MCP_PATH } = require('./lib/agent-server');
const { DEFAULT_FIX_PROMPT, normaliseFixPrompt } = require('./lib/fix-prompt');
// --- Diagnostic log ---------------------------------------------------------
//
// A packaged build has no terminal a user can see, so the console.error
// calls scattered through this file (worker stderr, spawn failures, decrypt
// failures...) go nowhere useful outside a dev shell - see the
// Troubleshooting section in the README. Mirroring them to a plain-text file
// in userData gives a packaged user something to open (Help > Open Log
// Folder) or attach to a bug report. Truncated fresh on every launch - this
// is "what happened this session", not a persistent history, so it can't
// grow unbounded over months of use.
const LOG_PATH = path.join(app.getPath('userData'), 'main.log');
try {
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
fs.writeFileSync(LOG_PATH, `LastMilePDF ${app.getVersion()} started ${new Date().toISOString()}\n`);
} catch {
// Logging must never be the thing that crashes the app.
}
function formatLogArg(value) {
if (value instanceof Error) return value.stack || String(value);
if (typeof value === 'object' && value !== null) {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
}
// Wrapping console.error once here (rather than editing every call site)
// mirrors every existing and future console.error call into the log file
// for free.
const originalConsoleError = console.error.bind(console);
console.error = (...args) => {
originalConsoleError(...args);
try {
fs.appendFileSync(LOG_PATH, `[${new Date().toISOString()}] ${args.map(formatLogArg).join(' ')}\n`);
} catch {
// best-effort
}
};
// --- Python sidecar -------------------------------------------------------
// In a packaged build there's no Python interpreter on the user's machine,
// so the worker ships as a PyInstaller-compiled exe (see python/tag_worker.py
// and the "build:worker" script) under extraResources. In dev, fall back to
// a project-local .venv (see README setup) - self-contained and side-steps
// system/user-site-packages resolution being unreliable on some machines -
// then PYTHON_BIN, then "python"/"python3" on PATH.
function packagedWorkerPath() {
const exeName = process.platform === 'win32' ? 'tag_worker.exe' : 'tag_worker';
return path.join(process.resourcesPath, 'python', exeName);
}
function defaultPythonBin() {
const venvPython = process.platform === 'win32'
? path.join(__dirname, '.venv', 'Scripts', 'python.exe')
: path.join(__dirname, '.venv', 'bin', 'python');
if (fs.existsSync(venvPython)) return venvPython;
return process.platform === 'win32' ? 'python' : 'python3';
}
// When packaged, the worker is a standalone exe invoked directly (no script
// argument); in dev it's PYTHON_BIN running tag_worker.py.
const WORKER_COMMAND = app.isPackaged
? { bin: packagedWorkerPath(), args: [] }
: { bin: process.env.PYTHON_BIN || defaultPythonBin(), args: [path.join(__dirname, 'python', 'tag_worker.py')] };
let workerProcess = null;
let requestCounter = 0;
// The undo-group token of Claude's open editing session, or null - set and
// cleared by the renderer (see 'agent:set-undo-group' below, and openSession()
// / closeSession() in renderer/agent.js). Safe to leave set by accident only
// in the sense that matters: the user's input is locked for exactly as long
// as it is set, and a reloaded window clears it.
let activeUndoGroup = null;
const pendingRequests = new Map(); // id -> { resolve, reject }
// Set when the worker reports an error with no id to match against a
// pending request (e.g. it fails at startup, before any request was
// sent - see tag_worker.py's pikepdf import check), or when the process
// can't be spawned at all. Surfaced as the rejection reason instead of the
// generic "exited unexpectedly" message. Module-scoped rather than local to
// startWorker() so callWorker() can still report it once the process is
// gone and there's nothing left to attach a handler to.
let lastWorkerError = null;
function failPendingRequests(reason) {
const error = new Error(reason);
for (const { reject } of pendingRequests.values()) reject(error);
pendingRequests.clear();
}
function startWorker() {
lastWorkerError = null;
const child = spawn(WORKER_COMMAND.bin, WORKER_COMMAND.args, {
stdio: ['pipe', 'pipe', 'pipe'],
});
workerProcess = child;
// spawn() reports a failure to *launch* asynchronously via 'error' rather
// than by throwing, and Node turns an 'error' event with no listener into
// an uncaught exception - which would take the whole main process down.
// startWorker() runs from app.whenReady() before createWindow(), so that
// means no window and no message at all: exactly the packaged-build case
// where tag_worker.exe didn't ship or got quarantined by antivirus.
// Handle it and let the renderer surface it through the normal error path
// instead. 'exit' may or may not follow an 'error', so settle whatever is
// in flight here rather than relying on the exit handler to do it.
child.on('error', (/** @type {NodeJS.ErrnoException} */ err) => {
console.error('[tag_worker] failed to start:', err);
lastWorkerError = `Could not start the PDF worker process (${err.code || err.message}). ` + (
app.isPackaged
? 'The bundled tag_worker executable is missing or was blocked from running.'
: 'Check that the .venv exists and has pikepdf installed - see the README setup steps.'
);
if (workerProcess === child) workerProcess = null;
failPendingRequests(lastWorkerError);
});
// Writing to a worker that has already died surfaces as an EPIPE 'error'
// on the stream as well as through write()'s callback - and an unlistened
// 'error' on a stream is another uncaught exception. The write callback
// and the exit handler below already reject whatever was in flight, so
// this only needs to keep the process alive.
child.stdin.on('error', (err) => {
console.error('[tag_worker:stdin]', err);
});
const stdoutLines = readline.createInterface({ input: child.stdout });
stdoutLines.on('line', (line) => {
if (!line.trim()) return;
let message;
try {
message = JSON.parse(line);
} catch (err) {
console.error('[tag_worker] sent non-JSON line:', line);
return;
}
const pending = pendingRequests.get(message.id);
if (!pending) {
if (message.error) {
lastWorkerError = message.error;
console.error('[tag_worker] error with no matching request:', message.error);
}
return; // stray/duplicate response, ignore
}
pendingRequests.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error));
} else {
pending.resolve(message.result);
}
});
// Surface Python tracebacks in the main-process console during development.
child.stderr.on('data', (chunk) => {
console.error('[tag_worker:stderr]', chunk.toString());
});
child.on('exit', (code) => {
console.error(`[tag_worker] exited with code ${code}`);
failPendingRequests(lastWorkerError || 'PDF worker process exited unexpectedly.');
// Only if this is still the live worker - a restart may already have
// put a newer process in place by the time an old one's exit lands.
if (workerProcess === child) workerProcess = null;
});
}
/**
* Send a command to the Python sidecar and resolve with its "result" field.
* Rejects if the worker responds with an "error" field, or dies mid-request.
*/
function callWorker(cmd, params = {}) {
if (!workerProcess) startWorker();
// A failed spawn clears workerProcess again from its own 'error' handler,
// but that fires on a later tick - so a just-restarted worker can still be
// a doomed process with a dead stdin right now. Reject with whatever
// reason we have rather than writing into it.
if (!workerProcess || !workerProcess.stdin || !workerProcess.stdin.writable) {
return Promise.reject(new Error(lastWorkerError || 'The PDF worker process is not running.'));
}
const id = ++requestCounter;
return new Promise((resolve, reject) => {
pendingRequests.set(id, { resolve, reject });
// While Claude has an editing session open, every request carries its
// token, and the worker folds the session's edits into one undo step
// (see _push_undo_snapshot() in tag_worker.py). Added here rather than at
// each of the forty-odd call sites because the session can reach any of
// them - its edits go through the same window.api methods the user's do.
const group = activeUndoGroup ? { undoGroup: activeUndoGroup } : {};
const payload = JSON.stringify({ id, cmd, ...params, ...group }) + '\n';
workerProcess.stdin.write(payload, (err) => {
if (err) {
pendingRequests.delete(id);
reject(err);
}
});
});
}
// --- Settings (AI provider + API keys) --------------------------------------
//
// BYOK (bring your own key): "Fix with AI" (see the ai:fix-actual-text
// handler below) calls an AI provider directly with a key the user supplies
// and pays for themselves - this app never holds or proxies a shared key.
// Two provider slots exist side by side: the built-in Anthropic one, and a
// single "custom" slot for any OpenAI chat-completions-compatible endpoint
// (e.g. a university-hosted service) - a base URL, an API key, and a model
// name the user supplies. `aiProvider` in settings.json picks which slot the
// AI handlers below read from at request time; switching the selector
// doesn't discard the other slot's saved values. Every key is encrypted at
// rest via Electron's safeStorage (OS keychain/DPAPI-backed) and stored
// alongside a small settings.json in the user's data dir; only main.js ever
// touches a decrypted value, since the renderer has no Node access and
// shouldn't need to.
const SETTINGS_PATH = path.join(app.getPath('userData'), 'settings.json');
function readSettingsFile() {
try {
return JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
} catch {
return {};
}
}
function writeSettingsFile(settings) {
fs.mkdirSync(path.dirname(SETTINGS_PATH), { recursive: true });
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
}
// --- Recent files ------------------------------------------------------
//
// File > Open Recent. Stored in the same settings.json as everything else
// here, capped so it can't grow without bound. Newest first; re-opening an
// already-listed path just moves it back to the top rather than
// duplicating it.
const RECENT_FILES_MAX = 10;
function getRecentFiles() {
const list = readSettingsFile().recentFiles;
return Array.isArray(list) ? list : [];
}
function addRecentFile(filePath) {
const settings = readSettingsFile();
const existing = Array.isArray(settings.recentFiles) ? settings.recentFiles : [];
settings.recentFiles = [filePath, ...existing.filter((p) => p !== filePath)].slice(0, RECENT_FILES_MAX);
writeSettingsFile(settings);
}
// Drops one path without touching the rest - used when File > Open Recent
// points at a file that's since been moved or deleted, so it stops showing
// up (and failing) on every future launch.
function removeRecentFile(filePath) {
const settings = readSettingsFile();
const existing = Array.isArray(settings.recentFiles) ? settings.recentFiles : [];
settings.recentFiles = existing.filter((p) => p !== filePath);
writeSettingsFile(settings);
}
function clearRecentFiles() {
const settings = readSettingsFile();
settings.recentFiles = [];
writeSettingsFile(settings);
}
function hasStoredApiKey() {
return typeof readSettingsFile().anthropicApiKey === 'string';
}
function getStoredApiKey() {
const encrypted = readSettingsFile().anthropicApiKey;
if (!encrypted || !safeStorage.isEncryptionAvailable()) return null;
try {
return safeStorage.decryptString(Buffer.from(encrypted, 'base64'));
} catch (err) {
console.error('[settings] failed to decrypt stored API key:', err);
return null;
}
}
function setStoredApiKey(key) {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error('This system has no OS-level credential store available to encrypt the key.');
}
const settings = readSettingsFile();
settings.anthropicApiKey = safeStorage.encryptString(key).toString('base64');
writeSettingsFile(settings);
}
function clearStoredApiKey() {
const settings = readSettingsFile();
delete settings.anthropicApiKey;
writeSettingsFile(settings);
}
// The exact dropdown selection from the Settings dialog - 'anthropic', or
// any provider id the renderer defines (a named preset like 'openai' or
// 'purdue-genai', or 'custom' for a manually-entered endpoint). main.js
// treats every value other than 'anthropic' identically (the generic
// OpenAI-compatible path - see customChatCompletion() below) and never
// needs to know the actual list of presets, so a provider the renderer adds
// or removes later just works without a main.js change. Defaults to
// 'anthropic' only when nothing has been saved yet; an unrecognized string
// is preserved as-is rather than silently reset, so a provider the user
// picked with this build still round-trips correctly even if a future
// version's renderer no longer offers it as a preset (it still behaves as a
// custom OpenAI-compatible endpoint - it just won't match a named preset's
// autofill in the dialog).
function getAiProvider() {
const provider = readSettingsFile().aiProvider;
return typeof provider === 'string' && provider ? provider : 'anthropic';
}
function setAiProvider(provider) {
const settings = readSettingsFile();
settings.aiProvider = provider;
writeSettingsFile(settings);
}
// Per-provider BYOK storage - keyed by the same provider id as
// getAiProvider() above, so switching between e.g. OpenAI and a
// university-hosted "Custom" endpoint remembers each one's own key and
// config instead of the two overwriting a single shared slot (the original,
// buggy design: only one custom-provider key/config existed, so saving a
// key for one provider silently clobbered whatever was saved for another).
// Keys are encrypted individually via safeStorage, same as the Anthropic
// key above; baseUrl/model aren't secret, so they're stored in plain text.
function getProviderConfigs() {
const configs = readSettingsFile().providerConfigs;
return configs && typeof configs === 'object' ? configs : {};
}
function getCustomProviderConfig(providerId) {
const config = getProviderConfigs()[providerId];
return {
baseUrl: config && typeof config.baseUrl === 'string' ? config.baseUrl : '',
model: config && typeof config.model === 'string' ? config.model : '',
};
}
function setCustomProviderConfig(providerId, baseUrl, model) {
const settings = readSettingsFile();
const configs = getProviderConfigs();
configs[providerId] = { baseUrl, model };
settings.providerConfigs = configs;
writeSettingsFile(settings);
}
function getProviderApiKeys() {
const keys = readSettingsFile().providerApiKeys;
return keys && typeof keys === 'object' ? keys : {};
}
function hasStoredCustomApiKey(providerId) {
return typeof getProviderApiKeys()[providerId] === 'string';
}
function getStoredCustomApiKey(providerId) {
const encrypted = getProviderApiKeys()[providerId];
if (!encrypted || !safeStorage.isEncryptionAvailable()) return null;
try {
return safeStorage.decryptString(Buffer.from(encrypted, 'base64'));
} catch (err) {
console.error(`[settings] failed to decrypt stored API key for provider "${providerId}":`, err);
return null;
}
}
function setStoredCustomApiKey(providerId, key) {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error('This system has no OS-level credential store available to encrypt the key.');
}
const settings = readSettingsFile();
const keys = getProviderApiKeys();
keys[providerId] = safeStorage.encryptString(key).toString('base64');
settings.providerApiKeys = keys;
writeSettingsFile(settings);
}
function clearStoredCustomApiKey(providerId) {
const settings = readSettingsFile();
const keys = getProviderApiKeys();
delete keys[providerId];
settings.providerApiKeys = keys;
writeSettingsFile(settings);
}
// --- Color theme -------------------------------------------------------
//
// File > Settings > Preferences > Appearance. Three choices are offered but
// only two exist as palettes (see the header comment in styles.css): 'auto'
// is resolved here, never in CSS, so the renderer only ever deals with a
// real theme name and the stylesheet needs no media queries.
//
// Defaults to 'auto', so a first run matches whatever the machine is already
// set to rather than imposing dark on someone running a light desktop. The
// cost is that the default is no longer a fixed answer: getResolvedTheme()
// consults nativeTheme, which is only correct once applyNativeThemeSource()
// has put themeSource back to 'system'. That call is the first thing in
// app.whenReady(), before the window exists - keep it there.
const THEMES = ['auto', 'dark', 'light'];
/** The ground each theme paints, mirroring --bg in styles.css. Used for the
* window's own backgroundColor, which is what shows for the few frames
* before the renderer paints - the wrong value here is a visible flash. */
const THEME_BACKGROUNDS = {
dark: '#1b1c21',
light: '#ffffff',
};
function getThemePreference() {
const stored = readSettingsFile().theme;
// 'accessible' was this palette's name while there were two light themes.
// The weaker one is gone and this is simply 'light' now, so anyone who had
// picked it keeps the theme they chose instead of being silently reset to
// dark by the validation below. Mapped on read rather than rewritten on
// disk: idempotent, and it costs nothing to leave in place.
if (stored === 'accessible') return 'light';
// Validated rather than trusted: a hand-edited settings.json holding a
// theme name that no longer exists would otherwise stamp an attribute no
// block matches, and every palette token would fall back to dark's.
// Anything unrecognised - including a fresh install with no theme key at
// all - lands on 'auto' and follows the OS.
return THEMES.includes(stored) ? stored : 'auto';
}
/** The stored preference with 'auto' collapsed to whatever the OS is asking
* for. Always 'dark' or 'light' - never 'auto'. */
function getResolvedTheme() {
const pref = getThemePreference();
if (pref !== 'auto') return pref;
// Safe to read as the OS's answer only because themeSource is held at
// 'system' whenever the preference is 'auto' - see the warning in
// applyNativeThemeSource() below.
return nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
}
// The parts of the window Electron and Windows draw rather than the
// stylesheet - the menu bar, the title bar, context menus, DevTools - do not
// see our CSS at all. themeSource is the only lever on them.
//
// Two limits worth knowing, neither of them ours to fix:
// * It is a three-way switch (system/light/dark), with no way to express
// "light, but at 7:1". So the frame gets plain light for our light
// theme: the right half of the pair, since its ground is white, but the
// window chrome will not carry the theme's stronger contrast.
// * On Windows the title bar reliably picks this up when it is set before
// the window is created; changing it on a window that already exists is
// long-standing flaky in Electron (electron#23479, electron#27100). The
// menu bar and context menus do update live. So a theme switch may leave
// the title bar behind until the next launch.
//
// WARNING: while themeSource is overridden, nativeTheme.shouldUseDarkColors
// returns the override instead of the OS setting. Pinning it to 'dark' for an
// explicit dark choice would therefore freeze the answer getResolvedTheme()
// gives for 'auto', and Auto would stop tracking the OS for good. Hence
// 'system' for 'auto' - that is load-bearing, not tidiness.
function applyNativeThemeSource(pref) {
nativeTheme.themeSource = pref === 'auto' ? 'system' : pref;
}
function setThemePreference(value) {
if (!THEMES.includes(value)) return;
const settings = readSettingsFile();
settings.theme = value;
writeSettingsFile(settings);
// After the write, so the 'updated' event this fires sees the new
// preference and takes the early return in the nativeTheme handler.
applyNativeThemeSource(value);
}
// Whether the selected tag's role is drawn as a label on its highlight
// box (File > Settings > Preferences). Persisted in the same settings.json
// as the API key so it's remembered between sessions.
function getShowTagTypeLabel() {
return readSettingsFile().showTagTypeLabel !== false; // default on
}
function setShowTagTypeLabel(value) {
const settings = readSettingsFile();
settings.showTagTypeLabel = value;
writeSettingsFile(settings);
}
// Whether finishing an AI batch operation (e.g. "Fix All Actual Text") pops
// a desktop notification / plays a chime (File > Settings > Preferences).
// Persisted the same way as showTagTypeLabel above.
function getNotifyDesktop() {
return readSettingsFile().notifyDesktop !== false; // default on
}
function setNotifyDesktop(value) {
const settings = readSettingsFile();
settings.notifyDesktop = value;
writeSettingsFile(settings);
}
function getNotifyChime() {
return readSettingsFile().notifyChime !== false; // default on
}
function setNotifyChime(value) {
const settings = readSettingsFile();
settings.notifyChime = value;
writeSettingsFile(settings);
}
// The physical key (KeyboardEvent.code, e.g. "CapsLock") the user has
// designated as an extra shortcut for the Tag Tree/Bookmarks Delete action
// (File > Settings > Preferences), so it can be pressed with the opposite
// hand from the arrow keys used to step through the tree. null when unset.
// Persisted the same way as showTagTypeLabel above.
function getExtraDeleteKeyCode() {
const value = readSettingsFile().extraDeleteKeyCode;
return typeof value === 'string' ? value : null;
}
function setExtraDeleteKeyCode(value) {
const settings = readSettingsFile();
settings.extraDeleteKeyCode = value;
writeSettingsFile(settings);
}
// Per-action keyboard shortcuts for the Tag Tree's role-conversion shortcuts
// (1-6/P/L/I/T/R/D/H/F/C/J) and for Proofread Mode's previous/next-tag step
// (Page Up/Page Down), each a plain { actionId: KeyboardEvent.key } map
// (File > Settings > Preferences). main.js stores whatever object it's
// given as-is - it doesn't know the current set of action ids or their
// default keys, since that list lives with the renderer code that actually
// dispatches on it (see TAG_SHORTCUT_ACTIONS/PROOFREAD_SHORTCUT_ACTIONS in
// state.js) - the renderer merges this over its own defaults after loading.
function getTagShortcuts() {
const value = readSettingsFile().tagShortcuts;
return value && typeof value === 'object' ? value : {};
}
function setTagShortcuts(value) {
const settings = readSettingsFile();
settings.tagShortcuts = value;
writeSettingsFile(settings);
}
function getProofreadShortcuts() {
const value = readSettingsFile().proofreadShortcuts;
return value && typeof value === 'object' ? value : {};
}
function setProofreadShortcuts(value) {
const settings = readSettingsFile();
settings.proofreadShortcuts = value;
writeSettingsFile(settings);
}
// The two view settings Proofread Mode carries over from one reading
// session to the next: whether Show AT Changes is on, and what the tree
// filter is set to (see applyProofreadViewPrefs() in proofread.js). Stored
// as one { showAtChanges, filter } object, and null until the user has
// actually changed one of them while proofreading - "no preference logged
// yet" is a distinct answer from "logged as off/All", since it's what
// selects the mode's own defaults rather than the user's.
//
// Not a Preferences item: it's written by using the mode, not by a dialog.
// main.js doesn't validate the shape beyond "an object" - the renderer owns
// the set of valid filter values (see state.filter in state.js) and
// re-checks on the way back out, the same arrangement as the shortcut maps
// above.
function getProofreadViewPrefs() {
const value = readSettingsFile().proofreadViewPrefs;
return value && typeof value === 'object' ? value : null;
}
function setProofreadViewPrefs(value) {
const settings = readSettingsFile();
settings.proofreadViewPrefs = value;
writeSettingsFile(settings);
}
// --- Per-file view state ------------------------------------------------
//
// Where the user was in a document the last time they had it open: which
// tag was selected, which tags they had expanded, how far down the tag tree
// was scrolled, and which page the preview was on. Reopening a PDF then
// picks up the reading position rather than dropping the user back at the
// structure root with everything collapsed - which on a long document meant
// re-expanding the same dozen levels every session.
//
// One record per file path, newest first and capped the same way
// recentFiles is: a file the user hasn't touched in twenty documents' time
// has almost certainly moved on, and the records are the only thing here
// that grows with the size of the document rather than being a fixed handful
// of values.
//
// main.js stores whatever object it's handed, the same arrangement as the
// shortcut maps and proofreadViewPrefs above: what a valid record looks like
// depends on the renderer's node ids and tree rendering, so the renderer is
// what validates one on the way back out (see view-memory.js). What main.js
// does own is the keying and the cap.
const FILE_VIEW_STATES_MAX = 20;
/** @param {Record<string, any>} [settings] An already-read settings object, to save reading the file again. */
function readFileViewStates(settings = readSettingsFile()) {
const list = settings.fileViewStates;
return Array.isArray(list) ? list.filter((entry) => entry && typeof entry.path === 'string') : [];
}
function getFileViewState(filePath) {
const entry = readFileViewStates().find((item) => item.path === filePath);
return entry && entry.view && typeof entry.view === 'object' ? entry.view : null;
}
// A null/undefined `view` drops the record instead of storing an empty one -
// that's how the renderer says "this document has nothing worth remembering"
// (no tree, or nothing selected yet), and leaving the previous record in
// place would restore a position the user has since navigated away from.
function setFileViewState(filePath, view) {
const settings = readSettingsFile();
const existing = readFileViewStates(settings).filter((item) => item.path !== filePath);
settings.fileViewStates = (view && typeof view === 'object'
? [{ path: filePath, view }, ...existing]
: existing
).slice(0, FILE_VIEW_STATES_MAX);
writeSettingsFile(settings);
}
// Whether the renderer periodically saves the open document to disk on its
// own, in addition to an explicit Save (File > Settings > Preferences).
// Persisted the same way as showTagTypeLabel above, but defaults off - unlike
// the other Preferences here, this one writes to the user's file without an
// explicit Save, so it should be opted into rather than assumed.
function getAutoSaveEnabled() {
return readSettingsFile().autoSaveEnabled === true; // default off
}
function setAutoSaveEnabled(value) {
const settings = readSettingsFile();
settings.autoSaveEnabled = value;
writeSettingsFile(settings);
}
// Whether to silently check GitHub for a newer release on launch (File >
// Settings > Preferences). Persisted the same way as showTagTypeLabel
// above, but defaults ON, unlike autoSaveEnabled - this only ever reads a
// public release feed and reports back through Help > About; it never
// writes anything or changes behavior on its own, so there's nothing here
// that needs opting into. See the Auto-update section below.
function getAutoCheckForUpdates() {
return readSettingsFile().autoCheckForUpdates !== false; // default on
}
function setAutoCheckForUpdates(value) {
const settings = readSettingsFile();
settings.autoCheckForUpdates = value;
writeSettingsFile(settings);
}
// Whether the local MCP server Claude connects to is running (File >
// Settings > Desktop Agents). Defaults off, for the same
// reason autoSaveEnabled does and then some: it opens a port, so it should
// be something the user turned on rather than something they discover.
function getAgentServerEnabled() {
return readSettingsFile().agentServerEnabled === true; // default off
}
function setAgentServerEnabled(value) {
const settings = readSettingsFile();
settings.agentServerEnabled = value;
writeSettingsFile(settings);
}
// The bearer token the server demands, made once and kept, so the
// `claude mcp add` command the user pasted keeps working across restarts.
// Stored in the clear, unlike the API keys above: it guards a port only this
// machine can reach, against other local processes - and any process that can
// read this file is already running as the user, with nothing left to gain
// from the token.
function getAgentServerToken() {
const settings = readSettingsFile();
if (typeof settings.agentServerToken === 'string' && settings.agentServerToken.length >= 32) {
return settings.agentServerToken;
}
settings.agentServerToken = crypto.randomBytes(24).toString('base64url');
writeSettingsFile(settings);
return settings.agentServerToken;
}
// The user's own "Fix this PDF" instructions (File > Settings > Desktop
// Agents), or null for the default in lib/fix-document-prompt.txt. Only
// a text that differs from the default is stored, so a user who never
// changed it - or reset it - picks up a revised default with the next update.
function getAgentFixPrompt() {
const value = readSettingsFile().agentFixPrompt;
return typeof value === 'string' && value.trim() ? value : null;
}
/** @param {string | null} value */
function setAgentFixPrompt(value) {
const settings = readSettingsFile();
const text = typeof value === 'string' ? normaliseFixPrompt(value) : '';
if (text && text !== DEFAULT_FIX_PROMPT) settings.agentFixPrompt = text;
else delete settings.agentFixPrompt;
writeSettingsFile(settings);
}
// The version that ran last time, which is how the What's New dialog below
// notices that an update has landed since. Written on every launch, so a
// crash between installing and reading it costs at most one summary.
function getLastRunVersion() {
const value = readSettingsFile().lastRunVersion;
return typeof value === 'string' ? value : null;
}
function setLastRunVersion(version) {
const settings = readSettingsFile();
settings.lastRunVersion = version;
writeSettingsFile(settings);
}
// Tools > Scripts… - user-defined sequences of the existing toolbar actions
// (Smartifact, Repair Orphaned Content, Scope Tables, Flatten All,
// Find/Replace, Fix All Actual Text (AI)), built and reordered in the
// renderer's Scripts dialog and run in order by the toolbar's "Run Script"
// button. Persisted the same way as the
// settings above; the shape of a script/step is the renderer's concern (see
// types/domain.d.ts's Script/ScriptStep), main.js just stores whatever it's
// handed. `activeScriptId` is which saved script (by id) the Run Script
// button currently triggers, or null if none has been assigned yet.
function getScripts() {
const scripts = readSettingsFile().scripts;
return Array.isArray(scripts) ? scripts : [];
}
function setScripts(scripts) {
const settings = readSettingsFile();
settings.scripts = scripts;
writeSettingsFile(settings);
}
function getActiveScriptId() {
const value = readSettingsFile().activeScriptId;
return typeof value === 'string' ? value : null;
}
function setActiveScriptId(id) {
const settings = readSettingsFile();
settings.activeScriptId = id;
writeSettingsFile(settings);
}
// --- AI batch timing log ----------------------------------------------------
//
// Lets the "Fix All Actual Text" progress dialog (see showAiBatchProgress()
// in renderer.js) show an upfront time estimate instead of just a generic
// "this may take a few minutes". Kept in its own file rather than
// settings.json since it's an operational log, not a user preference. Each
// entry is just {chars, ms, provider} for one completed batch request - no
// filenames, document content, or other identifying info - so the log can't
// leak anything about what a user has been editing. `provider` keys entries
// by which AI provider produced them (see getAiProvider() above) so a custom
// endpoint's speed - which can be wildly different from Anthropic's, e.g. a
// smaller self-hosted model - doesn't skew estimates for the other provider.
// Entries logged before the provider field existed have no `provider`, and
// are treated as 'anthropic' (see estimateAiBatchRange() below), since that
// was the only provider at the time.
const AI_BATCH_LOG_PATH = path.join(app.getPath('userData'), 'ai-batch-log.json');
const AI_BATCH_LOG_MAX_ENTRIES = 50; // recent-history average, not a lifetime total
function readAiBatchLog() {
try {
const log = JSON.parse(fs.readFileSync(AI_BATCH_LOG_PATH, 'utf8'));
return Array.isArray(log) ? log : [];
} catch {
return [];
}
}
function recordAiBatchTiming(chars, ms, provider) {
try {
const log = readAiBatchLog();
log.push({ chars, ms, provider });
while (log.length > AI_BATCH_LOG_MAX_ENTRIES) log.shift();
fs.mkdirSync(path.dirname(AI_BATCH_LOG_PATH), { recursive: true });
fs.writeFileSync(AI_BATCH_LOG_PATH, JSON.stringify(log));
} catch (err) {
console.error('[ai-batch-log] failed to record timing:', err);
}
}
// Interpolated quantile (q in [0, 1]) over an already-sorted array.
function quantile(sortedValues, q) {
const pos = (sortedValues.length - 1) * q;
const base = Math.floor(pos);
const rest = pos - base;
return sortedValues[base + 1] === undefined
? sortedValues[base]
: sortedValues[base] + rest * (sortedValues[base + 1] - sortedValues[base]);
}
// A {lowMs, highMs} range scaled to the requested size, or null with no
// history yet, so the caller can fall back to a generic hint instead of
// showing a made-up number. Uses the median ms/char rather than the mean -
// one unusually slow or fast run (API load, a network hiccup) shouldn't skew
// every estimate after it - and returns a spread rather than a single number
// since even same-sized requests vary run to run (how much correction the
// text actually needed, API load, etc). With too few runs to measure that
// spread directly, a flat +/-30% around the median stands in for it.
function estimateAiBatchRange(chars, provider) {
const log = readAiBatchLog().filter((entry) => (entry.provider || 'anthropic') === provider);
if (log.length === 0) return null;
const ratios = log.map((entry) => entry.ms / entry.chars).sort((a, b) => a - b);
const median = quantile(ratios, 0.5);
if (log.length < 4) {
return { lowMs: Math.round(median * chars * 0.7), highMs: Math.round(median * chars * 1.3) };
}
return {
lowMs: Math.round(quantile(ratios, 0.25) * chars),
highMs: Math.round(quantile(ratios, 0.75) * chars),
};
}
// --- Window -----------------------------------------------------------------
// Whether the renderer currently holds tag edits that aren't on disk. The
// renderer owns this - it's the side that knows whether an edit has landed
// since the last save - and pushes every change here via 'doc:dirty-changed'
// so the window-close guard below can read it synchronously.
let hasUnsavedChanges = false;
function createWindow() {
const win = new BrowserWindow({
width: 1280,
height: 860,
// 940, not 900: .workbench's three columns have minimums of 360 + 280 +
// 300 = 940px, and body sets overflow: hidden, so at 900 the details
// pane was clipped by 40px with no way to scroll to it - at 100% zoom,
// before any text resizing came into it.
minWidth: 940,
minHeight: 600,
// The ground shown for the frames between the window appearing and the
// renderer's first paint. Read from the saved theme rather than fixed,
// otherwise a light-theme user gets a dark flash on every launch.
backgroundColor: THEME_BACKGROUNDS[getResolvedTheme()],
icon: path.join(__dirname, 'build', 'icon.ico'),
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
// Closing the window is the one exit that can't be intercepted from the
// renderer, so the unsaved-changes prompt has to live here. 'close' isn't
// an async-friendly event - returning from the handler lets the window go
// - so this uses showMessageBoxSync rather than awaiting a promise that
// would resolve too late to matter. "Save" can't be answered
// synchronously either (the renderer owns the docId and the save path),
// so it hands off to the renderer and waits for 'doc:save-complete' to
// come back before actually destroying the window.
win.on('close', (e) => {
if (!hasUnsavedChanges) return;
e.preventDefault();
const choice = dialog.showMessageBoxSync(win, {
type: 'warning',
buttons: ['Save', "Don't Save", 'Cancel'],
defaultId: 0,
cancelId: 2,
title: 'Unsaved changes',
message: 'This PDF has unsaved tag changes.',
detail: 'Save them before closing?',
});
if (choice === 2) return; // Cancel - stay open, keep the changes
if (choice === 1) {
hasUnsavedChanges = false; // Don't Save - drop them and go
win.destroy();
return;
}
win.webContents.send('menu:save-and-close');
});
// A page that (re)loads has no editing session, whatever the last one was
// doing - so a token left behind by a renderer that died mid-session can't
// go on grouping the user's edits.
win.webContents.on('did-start-loading', () => { activeUndoGroup = null; });
win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
}
// --- Application menu ---------------------------------------------------
//
// Undo/Redo are app-level (they revert tag edits, not text-field input) and
// live in the renderer's own state, so the Edit menu can't run them
// directly - it just forwards the command as an IPC event and lets the
// renderer's existing performUndo()/performRedo() (see renderer.js) do the
// actual work, same as Ctrl+Z/Ctrl+Y do (there is no toolbar button for
// either - the menu is the only visible entry point). The accelerators
// below use registerAccelerator: false - they're shown in the menu for
// reference only and don't register as OS-level shortcuts, since the
// renderer already binds Ctrl+Z/Ctrl+Y/Ctrl+Shift+Z itself and steps aside
// when a text field is focused so native field-undo still works there - a
// real menu accelerator would fire regardless of focus and bypass that.
// The two items carry ids so the 'menu:undo-state-changed' handler below
// can grey them out when there's nothing to undo/redo, since without a
// toolbar button that's the only place this state is now shown.
// Open/Save/Save As/Close are driven the same way as Undo/Redo above: forwarded
// as IPC events to the renderer, which owns the docId and does the actual
// work (performOpen()/performSave()/performSaveAs()/performClose() in
// renderer.js) - Save picks between writing straight to the last-used path
// or falling back to a Save As dialog; Close just releases the current
// document without exiting the app.
/**
* Sends a menu event to the window whose menu was clicked.
*
* Electron types a menu click's window argument as BaseWindow, which has no
* `webContents`. The app menu here is only ever attached to a BrowserWindow,
* which does - so this records that assumption once, with a name, instead of
* repeating an inline cast at all fifteen call sites.
*
* @param {import('electron').BaseWindow | undefined} win
* @param {string} channel
* @param {...unknown} args
*/
function sendToWindow(win, channel, ...args) {
const browserWin = /** @type {import('electron').BrowserWindow | undefined} */ (win);
browserWin?.webContents.send(channel, ...args);
}
// Truncates a long path to its trailing MAX characters (keeping the file
// name, the most identifying part, intact) rather than showing the whole
// thing - a menu item's label has no room for a full path once it's a few
// directories deep.
function labelForRecentFile(filePath) {
const MAX = 60;
return filePath.length > MAX ? `…${filePath.slice(-(MAX - 1))}` : filePath;
}
// File > Open Recent's contents. Rebuilt (via a full buildAppMenu() call -
// there's no API to patch a single live submenu's items) every time the
// list changes: after a successful open/save-as (see openPdfAtPath() and
// the dialog:save-pdf handler below) and after Clear Recent Files here.
/** @returns {import('electron').MenuItemConstructorOptions[]} */
function buildRecentFilesSubmenu() {
const recent = getRecentFiles();
if (recent.length === 0) {
return [{ label: 'No Recent Files', enabled: false }];
}
return [
...recent.map((filePath) => ({
label: labelForRecentFile(filePath),
click: (_item, win) => sendToWindow(win, 'menu:open-recent', filePath),
})),
{ type: 'separator' },
{
label: 'Clear Recent Files',
click: () => {
clearRecentFiles();
Menu.setApplicationMenu(buildAppMenu());
},
},
];
}
// --- Live menu state ----------------------------------------------------
//
// buildAppMenu() throws away every MenuItem and builds new ones from the
// template below, so anything toggled *after* startup has to be read back