Repository navigation
Expand file tree
/
Copy pathmain.js
More file actions
5461 lines (5105 loc) · 264 KB
/
Copy pathmain.js
File metadata and controls
5461 lines (5105 loc) · 264 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
const { app, BrowserWindow, ipcMain, shell, session, nativeTheme, utilityProcess, Menu } = require('electron');
const { autoUpdater } = require('electron-updater');
const path = require('path');
const fs = require('fs');
const http = require('http');
const crypto = require('crypto');
// Right-click context-menu labels, translated in the RENDERER (which owns the app
// language) and pushed here via IPC so the native menu matches the in-app language
// rather than the OS locale. Null until the first push → role defaults are used.
let _ctxMenuLabels = null;
// The renderer owns the theme (Settings → Appearance); the window chrome is
// ours, so it has to be told. Called on boot and on every change.
ipcMain.on('app:native-theme', (_e, theme) => {
nativeTheme.themeSource = theme === 'light' ? 'light' : 'dark';
});
ipcMain.on('app:ctx-menu-labels', (_e, labels) => {
if (labels && typeof labels === 'object') _ctxMenuLabels = labels;
});
// ── Portable build — keep EVERYTHING on the stick ──────────────────────────
// The Windows `portable` target runs the app straight from a USB key or an
// external drive, with no installation (for machines where installing is not
// allowed). electron-builder sets PORTABLE_EXECUTABLE_DIR to the folder the
// .exe was launched from.
//
// Without this block a "portable" build is only half portable: Electron would
// still write accounts, cache, IndexedDB and logs to %APPDATA% on the HOST
// machine — so the user would leave a signed-in session behind on a shared PC
// AND would not find their inventory again on the next computer, which is the
// whole point of carrying the app around.
//
// Must run BEFORE electron-log is required (it resolves its file path from
// userData) and before anything else touches an app path.
const _portableDir = process.env.PORTABLE_EXECUTABLE_DIR;
if (_portableDir) {
try {
const dataDir = path.join(_portableDir, 'TigerStudioData');
fs.mkdirSync(dataDir, { recursive: true });
app.setPath('userData', dataDir);
app.setPath('sessionData', dataDir);
app.setPath('logs', path.join(dataDir, 'logs'));
} catch (e) {
// A read-only stick or a locked-down folder: fall back to the default
// location rather than refusing to start. The app still works, it just
// stores its data on the host.
console.warn('[portable] cannot use the executable folder, falling back:', e.message);
}
}
// ── Persistent logging ─────────────────────────────────────────────────────
// Writes to:
// Windows : %APPDATA%\Tiger Studio Manager\logs\main.log
// macOS : ~/Library/Logs/Tiger Studio Manager/main.log
// Linux : ~/.config/Tiger Studio Manager/logs/main.log
// Portable: <stick>\TigerStudioData\logs\main.log (see the block above)
const log = require('electron-log');
log.transports.file.maxSize = 5 * 1024 * 1024; // 5 MB max, auto-rotated
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}] [{level}] {text}';
Object.assign(console, log.functions); // console.log/warn/error → log file
autoUpdater.logger = log; // download / install steps land in main.log too
log.info(`Tiger Studio Manager starting — v${require('./package.json').version}`);
const db = require('./services/tigertagDbService');
// ── App display name (macOS menu bar, About dialog, Dock, etc.)
// package.json `name` is "tigertag-inventory" (npm-friendly slug). Force the
// human-readable product name so macOS shows "Tiger Studio Manager" in:
// - app menu (Apple menu → "About Tiger Studio Manager", "Quit Tiger Studio Manager")
// - Dock tooltip
// - Window menu items
// Must be called BEFORE app.whenReady() / before any window is created.
app.setName('Tiger Studio Manager');
// ── Chromium compositor tile budget (Retina + macOS Tahoe mitigation) ────────
// On the built-in Retina display (DPR 2) at fullscreen, the default tile-memory
// budget (~128 MB) is blown by the inventory grid + side panel + overlays,
// triggering thousands of `tile memory limits exceeded` warnings per second
// and producing the visible flashes the user reported. External monitors
// (DPR 1) stay under the budget and are unaffected.
//
// `force-gpu-mem-available-mb=1024` widens the GPU memory ceiling reported to
// the cc/tiles compositor → 8× more raster tiles fit before eviction. Tested
// live on M1 13" / macOS 26.2 Tahoe / Electron 41.3.0:
// - Without the switch: ~3 800 warnings per session, grid disappears when
// opening the side panel, sidecard renders without background, hover
// flashes the whole grid.
// - With the switch: 11 warnings at the fullscreen-resize moment only,
// side panel opens cleanly over an intact grid, zero hover flashes.
//
// 512 MB is NOT enough on a 16 GB M1 with 4 displays attached (tested);
// 1024 MB is the right level for this workload.
// Must run BEFORE app.whenReady().
app.commandLine.appendSwitch('force-gpu-mem-available-mb', '1024');
// Dev-drive (test harness): `npm run start:drive` opens Chromium's DevTools
// protocol on 127.0.0.1 so scripts/devdrive.mjs can drive the renderer.
// Opt-in AND dev-only: a packaged build never opens the port.
if (!app.isPackaged && process.env.TIGER_DEVDRIVE) {
app.commandLine.appendSwitch('remote-debugging-address', '127.0.0.1');
app.commandLine.appendSwitch('remote-debugging-port', process.env.TIGER_DEVDRIVE_PORT || '9339');
}
// ── Single-instance lock ────────────────────────────────────────────────────
// Prevent multiple Electron processes from sharing the same userData directory
// (which would deadlock IndexedDB / LevelDB — Firebase Auth, image cache, etc.).
// If a 2nd launch is attempted, focus the existing window and quit immediately.
const _hasInstanceLock = app.requestSingleInstanceLock();
if (!_hasInstanceLock) {
app.quit();
process.exit(0);
}
app.on('second-instance', (_event, argv) => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
// Windows / Linux: a `tigertag://…` click while the app is already running
// launches a 2nd instance whose argv carries the URL. Route it.
const url = _extractDeepLink(argv);
if (url) _handleDeepLink(url);
});
// ── Custom protocol — deep links (tigertag://friend/<code>) ─────────────────
// Lets a shared friend link (the tigersystem.io/friend/<code> landing page
// redirects to tigertag://friend/<code>) open the app and pre-fill the
// add-friend flow. The renderer parses the URL and only PRE-FILLS the code —
// the user still confirms, so a link can never auto-add or auto-accept anyone.
const DEEPLINK_SCHEME = 'tigertag';
if (process.defaultApp) {
// Dev (electron launched with a script path): the scheme must point at the
// electron binary + this script so the OS can relaunch us with the URL.
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(DEEPLINK_SCHEME, process.execPath, [path.resolve(process.argv[1])]);
}
} else {
app.setAsDefaultProtocolClient(DEEPLINK_SCHEME);
}
let _pendingDeepLink = null; // queued until the renderer signals it's ready
let _rendererDeepLinkReady = false;
function _extractDeepLink(argv) {
return (argv || []).find((a) => typeof a === 'string' && a.startsWith(DEEPLINK_SCHEME + '://')) || null;
}
function _handleDeepLink(url) {
if (!url) return;
if (_rendererDeepLinkReady && mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
mainWindow.webContents.send('deep-link', url);
} else {
_pendingDeepLink = url; // flushed on 'deep-link:ready'
}
}
// macOS delivers the URL here whether the app was already running or just
// launched by the click.
app.on('open-url', (event, url) => { event.preventDefault(); _handleDeepLink(url); });
// Renderer is ready to receive deep links → flush any that arrived early.
ipcMain.on('deep-link:ready', () => {
_rendererDeepLinkReady = true;
if (_pendingDeepLink) { const u = _pendingDeepLink; _pendingDeepLink = null; _handleDeepLink(u); }
});
// Cold start on Windows/Linux: the launch URL is in our own argv.
{
const _coldLink = _extractDeepLink(process.argv);
if (_coldLink) _pendingDeepLink = _coldLink;
}
// ── Minimal static file server so location.protocol === 'http:' (required by Firebase Auth)
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2':'font/woff2',
'.ttf': 'font/ttf',
};
let _devServer;
let _devPort;
// Port fixe = même origin à chaque démarrage → Firebase Auth + localStorage persistent
const RENDERER_PORT = 5784;
function startRendererServer(rendererDir) {
// Returns Promise<{ port }> — never rejects (all error paths resolve).
//
// The server always binds to 127.0.0.1 (explicit IPv4 loopback) to avoid
// the Windows 10 / Node.js 17+ pitfall where 'localhost' resolves to ::1
// (IPv6) and fails with EADDRNOTAVAIL when IPv6 is disabled on the machine.
//
// The BrowserWindow always loads from http://localhost:PORT (not 127.0.0.1)
// so Firebase Authentication sees a named host and Google sign-in works.
// Chromium resolves 'localhost' to 127.0.0.1 at TCP level, so the two
// sides always connect correctly.
const handler = (req, res) => {
let urlPath = req.url.split('?')[0];
// Image cache route — serves cached product thumbnails straight from
// imgCacheDir as real HTTP responses (proper Content-Type, browser
// HTTP cache, decoded-bitmap retention). Previously `img:get` returned
// a `data:base64,...` URL stored in `state.imgCache` and pasted into
// `<img src="data:...">`, which forced the browser to re-decode the
// bitmap every time the `<img>` was destroyed and re-created (full
// grid rebuild). With a stable HTTP URL, Chromium can keep the decoded
// bitmap alive across DOM operations → no visible flash on view
// switches, no flash on Firestore push.
if (urlPath.startsWith('/img-cache/')) {
const filename = urlPath.slice('/img-cache/'.length);
// Defence: only allow the {md5}.{ext} shape we write ourselves, no
// traversal, no arbitrary path read.
if (/^[a-f0-9]{32}\.[a-z0-9]+$/i.test(filename) && imgCacheDir) {
const filePath = path.join(imgCacheDir, filename);
try {
const data = fs.readFileSync(filePath);
const ext = path.extname(filename).toLowerCase();
res.writeHead(200, {
'Content-Type': MIME[ext] || 'image/jpeg',
'Cache-Control': 'public, max-age=86400',
});
res.end(data);
return;
} catch {
res.writeHead(404); res.end('Not found'); return;
}
}
res.writeHead(403); res.end('Forbidden'); return;
}
if (urlPath === '/' || urlPath === '') urlPath = '/inventory.html';
const filePath = path.join(rendererDir, urlPath);
try {
const data = fs.readFileSync(filePath);
const ext = path.extname(filePath).toLowerCase();
// `no-cache` = revalidate every load. The files come off the local disk, so
// it costs nothing — and without it a reload (Cmd+R) could run a stale ES
// module from Chromium's cache after an edit, hiding the change.
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
res.end(data);
} catch {
res.writeHead(404); res.end('Not found');
}
};
function tryBind(port) {
return new Promise((resolve) => {
const srv = http.createServer(handler);
srv.once('error', (err) => {
srv.close();
if (err.code === 'EADDRINUSE' && port !== 0) {
// Fixed port taken → retry on any available port
tryBind(0).then(resolve);
} else {
// Should never happen on 127.0.0.1 — resolve safely so the process
// keeps running rather than crashing with an unhandled rejection.
console.error(`[Renderer] bind failed: ${err.message}`);
resolve({ port: 0 });
}
});
srv.listen(port, '127.0.0.1', () => {
_devServer = srv;
_devPort = srv.address().port;
console.log(`[Renderer] http://127.0.0.1:${_devPort} (loadURL: localhost)`);
resolve({ port: _devPort });
});
});
}
return tryBind(RENDERER_PORT);
}
let imgCacheDir;
let mainWindow;
let splashWindow = null; // lightweight launch splash, closed once renderer is ready
let _mainRevealed = false; // guard so revealMainWindow() runs exactly once
let _camWindow = null; // detached camera wall window (optional)
// macOS: the red-button close should HIDE the main window (keeping the app +
// its whole renderer state — Firebase session, inventory, cameras — alive),
// not destroy it. `activate` (dock-click) then re-shows it instantly, with no
// reload and no re-login. `_isQuitting` distinguishes a genuine quit (Cmd+Q,
// updater, app.quit) — where the window must actually close — from a window
// close. Set true on `before-quit`; the migration guard resets it if it
// blocks that quit (see its handler below).
let _isQuitting = false;
app.on('before-quit', () => { _isQuitting = true; });
// autoUpdater.quitAndInstall() (Squirrel) emits `before-quit-for-update`, NOT the
// regular `before-quit`. Without latching here, the macOS `close` handler below
// sees `_isQuitting === false`, hides the window instead of closing it, the app
// never actually quits, and the downloaded update is never installed.
app.on('before-quit-for-update', () => { _isQuitting = true; });
// ── Splash gate ────────────────────────────────────────────────────────────
// Discord-style launch: a tiny frameless splash shows INSTANTLY (it's a
// self-contained data: URL, no server / Firebase needed), while the hidden
// main window loads + hydrates from cache off-screen. The renderer signals
// `studio:ready` once its first usable frame is painted; we then swap the
// main window in for the splash. A hard fallback timer guarantees the main
// window is always revealed even if the signal never arrives.
//
// Inline the TigerTag logo (white fill, transparent bg) so it paints with
// zero extra requests. Strip the XML prolog so it embeds cleanly in HTML;
// fall back to a lettermark if the file can't be read.
let _splashLogo = '';
try {
_splashLogo = fs.readFileSync(path.join(__dirname, 'assets', 'svg', 'logos', 'logo_tigertag_head.svg'), 'utf8')
.replace(/<\?xml[^>]*\?>/i, '')
.trim();
} catch (_) { _splashLogo = ''; }
const _splashMark = _splashLogo
? `<div class="logo">${_splashLogo}</div>`
: `<div class="mark">T</div>`;
function splashDataURL() {
return `data:text/html;charset=utf-8,` + encodeURIComponent(`
<!doctype html><html><head><meta charset="utf-8"><style>
html,body{margin:0;height:100%;background:transparent;overflow:hidden;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;-webkit-user-select:none;cursor:default}
.card{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;
background:#0e0e10;border-radius:16px;box-shadow:0 18px 60px rgba(0,0,0,.5);border:1px solid rgba(255,255,255,.06)}
.mark{width:120px;height:120px;border-radius:28px;background:linear-gradient(135deg,#ff7a18,#ffb056);
display:flex;align-items:center;justify-content:center;font-weight:800;font-size:56px;color:#0e0e10;letter-spacing:-1px;
box-shadow:0 8px 28px rgba(255,122,24,.35)}
/* The ANIMATED node is the plain wrapper (own GPU layer via will-change +
translate3d) while the drop-shadow filter stays STATIC on the svg in its own
layer — animating a filtered element re-rasterises the filter every frame on
the CPU, which stuttered exactly when startup pegs the processor. This way the
bounce is pure compositor work and stays smooth under load. */
.logo{display:flex;align-items:center;justify-content:center;
transform-origin:50% 100%;will-change:transform,opacity;
animation:drop .9s cubic-bezier(.3,.7,.4,1) both, bob 2.4s ease-in-out 1.15s infinite}
.logo svg{height:180px;width:auto;display:block;filter:drop-shadow(0 10px 30px rgba(255,122,24,.30))}
/* Drop-in bounce: falls from above, squashes on impact, two decaying rebounds… */
@keyframes drop{
0%{transform:translate3d(0,-150px,0);opacity:0}
40%{transform:translate3d(0,0,0) scale(1.07,.93);opacity:1}
60%{transform:translate3d(0,-24px,0)}
78%{transform:translate3d(0,0,0) scale(1.035,.965)}
89%{transform:translate3d(0,-8px,0)}
100%{transform:translate3d(0,0,0)}}
/* …then a gentle idle float while the app loads. */
@keyframes bob{0%,100%{transform:translate3d(0,0,0)}50%{transform:translate3d(0,-7px,0)}}
.name{color:#fff;font-size:22px;font-weight:700;letter-spacing:.3px}
.ver{color:rgba(255,255,255,.45);font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;margin-top:-10px}
.sub{color:rgba(255,255,255,.38);font-size:11px}
.bar{width:140px;height:3px;border-radius:3px;background:rgba(255,255,255,.08);overflow:hidden;margin-top:2px}
.bar i{display:block;width:40%;height:100%;border-radius:3px;background:linear-gradient(90deg,#ff7a18,#ffb056);
animation:slide 1s ease-in-out infinite}
@keyframes slide{0%{transform:translateX(-100%)}100%{transform:translateX(320%)}}
</style></head><body><div class="card">
${_splashMark}
<div class="name">Tiger Studio Manager</div>
<div class="ver">v${app.getVersion()}</div>
<div class="sub">Loading your studio…</div>
<div class="bar"><i></i></div>
</div></body></html>`);
}
function createSplash() {
splashWindow = new BrowserWindow({
width: 480, height: 460,
frame: false, transparent: true, resizable: false, movable: true,
center: true, show: true, hasShadow: false, alwaysOnTop: true,
skipTaskbar: true, focusable: false,
// backgroundThrottling:false — the splash is non-focusable, and Chromium's
// throttling heuristics can clamp an unfocused window's animations to a low
// frame rate exactly while startup loads the CPU. Keep it at full rate.
webPreferences: { contextIsolation: true, nodeIntegration: false, backgroundThrottling: false },
});
splashWindow.loadURL(splashDataURL());
splashWindow.on('closed', () => { splashWindow = null; });
}
function revealMainWindow() {
if (_mainRevealed) return;
_mainRevealed = true;
try { if (splashWindow && !splashWindow.isDestroyed()) splashWindow.close(); } catch (_) {}
splashWindow = null;
if (mainWindow && !mainWindow.isDestroyed()) {
// Come back the way the user left it: maximized / full screen are applied
// here, at reveal, because doing it on the hidden window would show it early.
const st = mainWindow._restoreState || {};
if (st.maximized) mainWindow.maximize();
mainWindow.show();
if (st.fullScreen) mainWindow.setFullScreen(true);
mainWindow.focus();
}
}
// NFC state — replayed to renderer on every (re)load
const _nfcReaders = new Map(); // name → reader object
const _nfcCardPresent = new Map(); // name → { uid, rawUid }
// ── Convert hex UID (e.g. "1D895E7C004A80") to decimal string used by TigerTag
// Normalise a raw UID string from nfc-pcsc to clean uppercase hex, matching
// the SDK's tag.uidHex format (e.g. "04:AB:CD" → "04ABCD").
function normalizeUid(raw) {
return raw ? raw.replace(/[:\s]/g, '').toUpperCase() : raw;
}
// ── TigerTag JS SDK ───────────────────────────────────────────────────────────
// The SDK (≥1.2.0) can lazily build a TigerTagDB that checks api.tigertag.io once
// a day and writes to the OS cache folder. Studio has its own reference-data
// service (services/tigertagDbService.js) and only uses the SDK to encode/decode
// bytes — so it runs strictly offline: no hidden network call, no stray cache.
process.env.TIGERTAG_OFFLINE = '1';
const { TigerTag } = require('tigertag');
// IPC payload — toRawDict() first, then rawApi() if TigerTag+.
/* The chip carries ONE unit byte for BOTH of its quantities — a real 1 kg tag
reads `measure: 1, measure_available: 1, id_unit: 35`. Our Firestore doc does
not follow that convention: `measure` is in the product's unit but
`weight_available` is always in GRAMS. The SDK's fromCloudDoc takes one of
each and stamps the doc's unit on both, so a kg spool went onto the chip with
its remaining weight multiplied by a thousand — and read back as 1 000 000 g.
Converted here, at the one boundary where a document becomes a chip.
The field is ALWAYS set, never dropped: fromCloudDoc falls back to
`doc.measure_gr` when it is missing, and that value is in grams too — leaving
it out reintroduced the very bug through the back door. A unit coarser than
the value rounds (1303 g on a kg tag becomes 1 kg); the resolution is the
format's, not ours, and the next weighing corrects it. */
const CHIP_GRAMS_PER_UNIT = { 10: 0.001, 21: 1, 35: 1000 }; // mg / g / kg
function _docForChip(doc) {
if (!doc) return doc;
const per = CHIP_GRAMS_PER_UNIT[doc.id_unit];
if (!per) return doc; // volume / size / area: not a weight
/* `measure_gr` is the fallback the SDK reaches for when the remaining weight
is absent — and it is in grams as well, so leaving the field unset put the
same thousandfold error on the chip by another route. Both candidates are
converted here; nothing gets to the SDK still expressed in grams. */
const grams = Number(doc.weight_available ?? doc.measure_gr);
if (!Number.isFinite(grams)) return doc; // nothing to convert
const inUnit = grams / per;
if (!Number.isInteger(inUnit)) {
console.warn(`[NFC] remaining weight ${grams} g rounds to ${Math.round(inUnit)} in unit ${doc.id_unit} — the chip cannot hold it exactly`);
}
return { ...doc, weight_available: Math.round(inUnit) };
}
async function _sdkPayload(tag, readerName = null) {
const raw = tag.toRawDict();
if (readerName) raw._readerName = readerName;
if (tag.apiUrl) {
try {
const api = await tag.rawApi();
raw._api = api; // attach full API response under _api key
} catch (e) {
console.warn('[NFC] rawApi() failed, chip data only:', e.message);
}
}
return raw;
}
// True only for an http(s) URL. Every path that hands a URL to the OS shell or
// to a new window goes through this; a scheme that is not provably http(s) is
// refused rather than blocked by name, so a scheme nobody thought of still fails
// closed.
function isSafeExternalUrl(u) {
try {
const p = new URL(String(u)).protocol;
return p === 'http:' || p === 'https:';
} catch { return false; }
}
// ── Main window size / position memory ─────────────────────────────────────
// <userData>/window-state.json = { x, y, width, height, maximized, fullScreen }.
// Saved whenever the window moves or resizes (debounced) and on close; restored
// at creation — but only if those bounds still land on a connected display
// (a monitor unplugged since last time would otherwise open the app off-screen).
const _winStatePath = () => path.join(app.getPath('userData'), 'window-state.json');
// First launch (nothing saved yet): open maximized — the app wants the room.
const WIN_DEFAULT = { width: 1280, height: 820, maximized: true };
function _readWindowState() {
let st = null;
try { st = JSON.parse(fs.readFileSync(_winStatePath(), 'utf8')); } catch (_) { return { ...WIN_DEFAULT }; }
const ok = n => Number.isFinite(n);
if (!st || !ok(st.width) || !ok(st.height)) return { ...WIN_DEFAULT };
const out = { width: Math.max(900, st.width), height: Math.max(600, st.height),
maximized: !!st.maximized, fullScreen: !!st.fullScreen };
if (ok(st.x) && ok(st.y)) {
const { screen } = require('electron');
// Visible if at least 100×50 px of the title-bar region overlaps a display.
const onScreen = screen.getAllDisplays().some(({ workArea: d }) =>
st.x + st.width - 100 > d.x && st.x + 100 < d.x + d.width &&
st.y + 50 > d.y && st.y < d.y + d.height - 50);
if (onScreen) { out.x = st.x; out.y = st.y; }
}
return out;
}
let _winStateTimer = null;
function _saveWindowState(win) {
if (!win || win.isDestroyed()) return;
try {
// getNormalBounds() = the size to come back to when un-maximized.
const b = (win.isMaximized() || win.isFullScreen()) ? win.getNormalBounds() : win.getBounds();
fs.writeFileSync(_winStatePath(), JSON.stringify({
...b, maximized: win.isMaximized(), fullScreen: win.isFullScreen(),
}));
} catch (e) { console.warn('[window] state save failed:', e.message); }
}
function _trackWindowState(win) {
const later = () => { clearTimeout(_winStateTimer); _winStateTimer = setTimeout(() => _saveWindowState(win), 400); };
['resize', 'move', 'maximize', 'unmaximize', 'enter-full-screen', 'leave-full-screen'].forEach(ev => win.on(ev, later));
win.on('close', () => { clearTimeout(_winStateTimer); _saveWindowState(win); });
}
// ── Create main window
function createWindow() {
// Reset the reveal latch so a window recreated from `activate` (macOS
// dock-click after the red-button close destroyed the previous one)
// runs its own splash→reveal cycle. Without this the new window stays
// hidden forever — `revealMainWindow` short-circuits on the stale flag.
_mainRevealed = false;
const winState = _readWindowState();
mainWindow = new BrowserWindow({
width: winState.width,
height: winState.height,
...(winState.x != null ? { x: winState.x, y: winState.y } : {}),
minWidth: 900,
minHeight: 600,
title: 'Tiger Studio Manager',
hasShadow: false,
// Splash gate: start hidden + dark so there's no white flash, then
// reveal only once the renderer signals its first usable paint
// (studio:ready) — or the safety fallback below fires.
show: false,
backgroundColor: '#0e0e10',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
webviewTag: true, // required for <webview> Creality camera (cross-origin JS injection)
},
});
mainWindow._restoreState = winState; // applied at reveal (maximize would show it early)
_trackWindowState(mainWindow);
// macOS: red-button close hides the window instead of destroying it, so the
// renderer (auth session, inventory, live cameras) survives and a dock-click
// brings it straight back. A real quit (Cmd+Q / updater) sets `_isQuitting`
// and is let through. Other platforms keep the default close→quit behaviour.
if (process.platform === 'darwin') {
mainWindow.on('close', (e) => {
if (!_isQuitting) {
e.preventDefault();
mainWindow.hide();
}
});
}
// Right-click context menu — native Cut/Copy/Paste/Select-all on any editable
// field (text/number/url inputs, textareas, contenteditable), plus Copy on any
// selected text. Uses `role`s for built-in behaviour, but OVERRIDES each label
// with the app-language string pushed from the renderer (`_ctxMenuLabels`), so the
// menu matches the in-app language rather than the OS locale. `editFlags` disable
// actions that don't apply (e.g. Paste with an empty clipboard, Cut with no selection).
mainWindow.webContents.on('context-menu', (_e, params) => {
const ef = params.editFlags || {};
const hasSel = !!(params.selectionText && params.selectionText.trim());
const L = _ctxMenuLabels || {}; // app-language labels; a falsy label → role default
const tpl = [];
if (params.isEditable) {
tpl.push({ role: 'cut', label: L.cut, enabled: !!ef.canCut });
tpl.push({ role: 'copy', label: L.copy, enabled: !!ef.canCopy });
tpl.push({ role: 'paste', label: L.paste, enabled: !!ef.canPaste });
tpl.push({ type: 'separator' });
tpl.push({ role: 'selectAll', label: L.selectAll, enabled: ef.canSelectAll !== false });
} else if (hasSel) {
tpl.push({ role: 'copy', label: L.copy, enabled: !!ef.canCopy });
}
if (tpl.length) Menu.buildFromTemplate(tpl).popup({ window: mainWindow });
});
// Primary reveal signal — renderer posts this after hydrating from cache.
ipcMain.once('studio:ready', revealMainWindow);
// Safety fallback — never leave the app invisible if the signal is missed
// (renderer crash, blocked script, etc.). 6 s is well past a normal cold
// start; the studio:ready path almost always wins far sooner.
setTimeout(revealMainWindow, 6000);
startRendererServer(__dirname).then(({ port }) => {
// Always load via 'localhost' (not '127.0.0.1') so Firebase Auth
// sees a named host and Google sign-in works on all platforms.
mainWindow.loadURL(`http://localhost:${port}/renderer/inventory.html`);
});
// Only http(s) may ever be handed to the OS shell. `javascript:`, `file://`,
// `smb://` and OS app-schemes are rejected: a renderer foothold — see the
// stored-XSS class in docs/reviews/ — would otherwise reach the shell through
// this channel and run something local. Paired with safeHref() in the
// renderer, which blocks the same schemes at render time; this is the second
// chokepoint, so a value that slips past the first one still cannot execute.
// Deliberately not a blocklist: anything not provably http(s) is refused.
// Navigation lock. The renderer never navigates itself (no location writes,
// no reloads), so any attempt to leave the app's own origin is either an
// injected link or a redirect — and the destination would inherit the preload
// bridge. Same-origin navigation stays allowed; an external http(s) target is
// handed to the browser instead; anything else is dropped.
mainWindow.webContents.on('will-navigate', (evt, url) => {
const here = mainWindow.webContents.getURL();
let sameOrigin = false;
try { sameOrigin = new URL(url).origin === new URL(here).origin; } catch { sameOrigin = false; }
if (sameOrigin) return;
evt.preventDefault();
if (isSafeExternalUrl(url)) shell.openExternal(url);
});
// Firebase auth popup → ouvrir en interne (postMessage doit fonctionner)
// Tous les autres liens → navigateur système
const CHROME_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('https://tigertag-connect.firebaseapp.com/__/auth/')) {
return {
action: 'allow',
overrideBrowserWindowOptions: {
width: 520,
height: 700,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: false, // nécessaire pour que window.opener.postMessage fonctionne
},
},
};
}
if (isSafeExternalUrl(url)) shell.openExternal(url);
return { action: 'deny' };
});
// Appliquer un vrai user-agent Chrome sur la fenêtre popup
// pour que Google ne bloque pas le webview Electron
mainWindow.webContents.on('did-create-window', (win) => {
win.webContents.setUserAgent(CHROME_UA);
// Aussi bloquer les redirections externes depuis le popup auth
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
});
}
// ── NFC / RFID reader
// nfc-pcsc wraps @pokusew/pcsclite which calls SCardEstablishContext() at init.
// On Windows machines without an active Smart Card service this call blocks the
// main V8 thread, preventing loadURL() from running and causing "Not Responding".
// On macOS, running pcsclite inside a Worker Thread crashes with SIGABRT because
// the native addon is not thread-safe (it stores the Node env pointer from the
// thread it was initialised on and asserts it is non-null in async callbacks).
//
// Solution: spawn nfc-pcsc in an Electron utilityProcess — a completely isolated
// process (separate heap, separate libuv loop) with no thread-safety concerns.
let _nfcChild = null;
const _nfcReadResolvers = new Map(); // reqId → { resolve, timeout }
const _nfcWriteResolvers = new Map(); // reqId → { resolve, timeout }
async function _onNfcMessage(msg) {
switch (msg.type) {
case 'init-error':
console.warn('[NFC] not available:', msg.message);
break;
case 'reader-connected':
console.log(`[NFC] Reader connected: ${msg.name}`);
_nfcReaders.set(msg.name, true);
mainWindow?.webContents.send('rfid-reader-update', { name: msg.name, connected: true });
break;
case 'reader-disconnected':
console.log(`[NFC] Reader disconnected: ${msg.name}`);
_nfcReaders.delete(msg.name);
_nfcCardPresent.delete(msg.name);
mainWindow?.webContents.send('rfid-reader-update', { name: msg.name, connected: false });
break;
case 'card': {
const uid = normalizeUid(msg.uid); // clean uppercase hex, matches SDK uidHex
_nfcCardPresent.set(msg.readerName, { uid });
mainWindow?.webContents.send('rfid-uid', uid);
mainWindow?.webContents.send('rfid-card-present', { readerName: msg.readerName, uid });
// Parse chip and send full tag data when pages were auto-read
// rawPagesHex starts at page 0x04 (UID already known from card event, no need to re-read pages 0-3)
if (msg.rawPagesHex) {
try {
const rawBytes = Buffer.from(msg.rawPagesHex, 'hex');
const uidBuf = Buffer.from(uid, 'hex'); // 7 bytes — provided natively by the reader
const tag = TigerTag.fromPages(uidBuf, rawBytes);
console.log(`[NFC] Card present on ${msg.readerName} — uid: ${tag.uidHex}`);
const payload = await _sdkPayload(tag, msg.readerName);
// Attach the raw user pages (0x04-0x27) so the renderer's chip census
// can back up a TigerTag+ signature on auto-read, without a 2nd scan.
payload._rawPagesHex = msg.rawPagesHex;
mainWindow?.webContents.send('rfid-tag-scanned', payload);
} catch (e) {
console.warn('[NFC] SDK parse failed:', e.message);
}
} else {
console.log(`[NFC] Card present on ${msg.readerName} — uid: ${uid} (no dump)`);
}
break;
}
case 'card-removed':
console.log(`[NFC] Card removed from ${msg.readerName}`);
_nfcCardPresent.delete(msg.readerName);
mainWindow?.webContents.send('rfid-card-present', { readerName: msg.readerName, uid: null, rawUid: null });
break;
case 'reader-error':
console.error(`[NFC] Reader error on ${msg.readerName}:`, msg.message);
break;
case 'nfc-error':
console.error('[NFC] NFC error:', msg.message);
break;
case 'read-result': {
const entry = _nfcReadResolvers.get(msg.reqId);
if (!entry) break;
clearTimeout(entry.timeout);
_nfcReadResolvers.delete(msg.reqId);
entry.resolve(msg.ok
? { ok: true, rawPagesHex: msg.rawPagesHex }
: { ok: false, error: msg.error });
break;
}
case 'write-result': {
const entry = _nfcWriteResolvers.get(msg.reqId);
if (!entry) break;
clearTimeout(entry.timeout);
_nfcWriteResolvers.delete(msg.reqId);
entry.resolve(msg.ok
? { ok: true, pagesWritten: msg.pagesWritten, verified: msg.verified, mismatchPages: msg.mismatchPages }
: { ok: false, error: msg.error });
break;
}
}
}
// TigerTag chip epoch = 2000-01-01 UTC. The chip timestamp field stores
// seconds since this epoch; passing Unix seconds would decode ~30 years late.
const _TT_EPOCH_MS = Date.UTC(2000, 0, 1);
function _nowChipTs() {
return Math.max(0, Math.floor((Date.now() - _TT_EPOCH_MS) / 1000));
}
function initNFC() {
const scriptPath = path.join(__dirname, 'services', 'nfc-process.js');
try {
_nfcChild = utilityProcess.fork(scriptPath);
} catch (err) {
console.warn('[NFC] utilityProcess not available:', err.message);
return;
}
_nfcChild.on('message', _onNfcMessage);
_nfcChild.on('exit', (code) => {
if (code !== 0) console.warn(`[NFC] Utility process exited with code ${code}`);
_nfcChild = null;
});
}
// ── USB scale (Dymo M-series) ──────────────────────────────────────────────
// Standard USB HID Scale (usage page 0x8D): 6-byte data reports at ~1 Hz —
// [reportId, status, unit, signed exponent, weight LSB, weight MSB].
// node-hid has no hot-plug events, so we poll the device list every 5 s while
// disconnected and rely on the read-error callback to detect unplug.
let _scaleDev = null;
let _scalePollTimer = null;
let _scaleInfo = null; // { product } while connected
const SCALE_VENDOR_DYMO = 0x0922;
const SCALE_STATUS = { 1:'fault', 2:'zero', 3:'motion', 4:'stable', 5:'negative', 6:'over' };
const SCALE_OZ_TO_G = 28.3495;
function _scaleBroadcast(channel, payload) {
mainWindow?.webContents.send(channel, payload);
}
function _scaleDecode(buf) {
if (!buf || buf.length < 6) return null;
const status = SCALE_STATUS[buf[1]] || 'unknown';
const unit = buf[2]; // 0x02 g, 0x0B oz, 0x0C lb (0x00 on the first frame after tare)
const exp = buf[3] > 127 ? buf[3] - 256 : buf[3];
let value = (buf[4] | (buf[5] << 8)) * Math.pow(10, exp);
let grams;
if (unit === 0x02) grams = value;
else if (unit === 0x0b) grams = value * SCALE_OZ_TO_G;
else if (unit === 0x0c) grams = value * SCALE_OZ_TO_G * 16;
else grams = 0; // unit 0x00 quirk right after tare — always a zero frame anyway
if (status === 'negative') grams = -grams;
return { status, grams: Math.round(grams) };
}
function _scaleDisconnected() {
try { _scaleDev?.close(); } catch (_) { /* already gone */ }
_scaleDev = null;
if (_scaleInfo) {
console.log('[Scale] Disconnected');
_scaleInfo = null;
_scaleBroadcast('usb-scale-update', { connected: false, product: null });
}
if (!_scalePollTimer) _scalePollTimer = setInterval(_tryOpenScale, 5000);
}
/* The HID enumeration MUST be the async one. `HID.devices()` is synchronous and
ran every 5 s on the main thread; on some Windows machines (slow HID / Bluetooth
drivers) one call takes seconds, so the whole app froze — every click, even a
window resize, waited minutes. `devicesAsync()` enumerates on a worker thread,
and `_scaleScanning` keeps a slow scan from stacking up behind the next tick. */
let _scaleScanning = false;
let _scaleSlowPoll = false; // polling slowed after a slow HID enumeration
async function _tryOpenScale() {
if (_scaleDev || _scaleScanning) return;
let HID;
try { HID = require('node-hid'); } catch (e) {
console.warn('[Scale] node-hid unavailable:', e.message);
clearInterval(_scalePollTimer); _scalePollTimer = null;
return;
}
let info;
_scaleScanning = true;
const t0 = Date.now();
try {
info = (await HID.devicesAsync()).find(d =>
d.vendorId === SCALE_VENDOR_DYMO && (d.usagePage === undefined || d.usagePage === 0x8d));
} catch (_) { return; }
finally {
_scaleScanning = false;
const ms = Date.now() - t0;
/* A machine whose HID enumeration takes seconds would spend its whole life
enumerating at a 5 s cadence (one user measured 10 s per scan). Back off
to once a minute there — plugging a scale in is rare, and still seen. */
if (ms > 2000) {
console.warn(`[Scale] USB scan took ${ms} ms — polling slowed to 60 s`);
if (_scalePollTimer && !_scaleSlowPoll) {
clearInterval(_scalePollTimer);
_scalePollTimer = setInterval(_tryOpenScale, 60000);
_scaleSlowPoll = true;
}
}
}
if (!info || _scaleDev) return;
try {
_scaleDev = new HID.HID(info.path);
} catch (e) {
console.warn('[Scale] open failed:', e.message);
return;
}
clearInterval(_scalePollTimer); _scalePollTimer = null;
_scaleInfo = { product: info.product || 'USB scale' };
console.log(`[Scale] Connected: ${_scaleInfo.product}`);
_scaleBroadcast('usb-scale-update', { connected: true, product: _scaleInfo.product });
_scaleDev.on('data', (buf) => {
const frame = _scaleDecode(buf);
if (frame) _scaleBroadcast('usb-scale-data', frame);
});
_scaleDev.on('error', () => _scaleDisconnected()); // unplug or read failure
}
function initUsbScale() {
_tryOpenScale();
if (!_scaleDev && !_scalePollTimer) _scalePollTimer = setInterval(_tryOpenScale, 5000);
}
// Renderer seeds its state on startup (events may have fired before it loaded).
ipcMain.handle('usb-scale:state', () => ({
connected: !!_scaleDev,
product: _scaleInfo?.product || null,
}));
// On-demand card read — called by renderer "Read" button in RFID tester.
// Delegates to the NFC utility process and parses the result here in the main process.
ipcMain.handle('rfid:read-now', async (_evt, readerName) => {
if (!_nfcChild) return { ok: false, error: 'NFC process not running' };
if (!_nfcReaders.has(readerName)) return { ok: false, error: 'Reader not connected' };
const card = _nfcCardPresent.get(readerName);
if (!card) return { ok: false, error: 'No card present' };
const reqId = Date.now() + Math.random();
const result = await new Promise((resolve) => {
const timeout = setTimeout(() => {
_nfcReadResolvers.delete(reqId);
resolve({ ok: false, error: 'Read timed out' });
}, 5000);
_nfcReadResolvers.set(reqId, { resolve, timeout });
_nfcChild.postMessage({ type: 'read-now', readerName, reqId });
});
if (!result.ok) return result;
let tagData = null;
try {
const rawBytes = Buffer.from(result.rawPagesHex, 'hex'); // pages 0x04-0x27, UID already in card.uid
const uidBuf = Buffer.from(card.uid, 'hex'); // 7-byte UID, provided natively by reader
const tag = TigerTag.fromPages(uidBuf, rawBytes);
tagData = await _sdkPayload(tag);
console.log('[NFC] read-now result: is_maker=%s is_plus=%s is_signed=%s', tag.isMaker, tag.isPlus, tag.isSigned);
} catch (e) {
console.warn('[NFC] read-now parse failed:', e.message);
}
return { ok: true, uid: card.uid, rawPagesHex: result.rawPagesHex, tagData };
});
// Tag write — called by the renderer to program a TigerTag chip.
//
// opts = {
// readerName : string — which reader holds the chip
// cloudDoc : object — Firestore cloud doc shape (id_brand, id_material, data1-data7, TD, …)
// patch : object | null — optional snake_case overrides applied AFTER fromCloudDoc()
// e.g. { td_raw: 5, custom_message: "Hello" }
// surgical : boolean — default true: only write pages that differ from current chip data
// false: always write all 20 user pages (0x04-0x17)
// }
//
// Returns { ok, pagesWritten } | { ok: false, error }
//
// Hardware note — NTAG213/215 page write limit:
// The WRITE command (0xA2) writes exactly 4 bytes (1 page) per APDU. There is no
// multi-page WRITE APDU for NTAG. "Bulk" in nfc-pcsc means calling write() in a
// loop internally — still 1 APDU per page. Surgical mode minimises round-trips by
// skipping pages whose bytes haven't changed.
ipcMain.handle('rfid:write-now', async (_evt, opts) => {
const { readerName, cloudDoc, patch = null, surgical = true } = opts || {};
if (!_nfcChild) return { ok: false, error: 'NFC process not running' };
if (!_nfcReaders.has(readerName)) return { ok: false, error: 'Reader not connected' };
const card = _nfcCardPresent.get(readerName);
if (!card) return { ok: false, error: 'No card present' };
// ── 1. Build the 80-byte payload from cloud doc ─────────────────────────────
let newBytes;
try {
let tag = TigerTag.fromCloudDoc(_docForChip(cloudDoc)).patch(_tagInfoPatch(cloudDoc));
if (patch && Object.keys(patch).length > 0) tag.patchFromRawDict(patch);
newBytes = tag.toBytes(); // 80 bytes covering pages 0x04-0x17
} catch (e) {
return { ok: false, error: `SDK build failed: ${e.message}` };
}
// ── 2. Surgical diff — read current chip, skip unchanged pages ───────────────
let pages; // [{ index, hexData }] — page indices are absolute (4 = page 0x04)
if (surgical) {
// Read current chip bytes to compare
const reqId = Date.now() + Math.random();
const readResult = await new Promise((resolve) => {
const timeout = setTimeout(() => {
_nfcReadResolvers.delete(reqId);
resolve({ ok: false, error: 'Read timed out' });
}, 5000);
_nfcReadResolvers.set(reqId, { resolve, timeout });
_nfcChild.postMessage({ type: 'read-now', readerName, reqId });
});
if (!readResult.ok) {
// Fall back to full write if read fails
console.warn('[NFC] write surgical read failed, falling back to full write:', readResult.error);
pages = _pagesToWrite(newBytes, null);
} else {
// rawPagesHex now starts at page 0x04 — first 80 bytes are pages 0x04-0x17 (toBytes() range)
const oldUserBytes = Buffer.from(readResult.rawPagesHex, 'hex').slice(0, 80);
pages = _pagesToWrite(_keepChipIdentity(newBytes, oldUserBytes), oldUserBytes);
}
} else {
pages = _pagesToWrite(newBytes, null); // full write — all 20 pages
}
if (pages.length === 0) {
console.log('[NFC] write-now: chip already up-to-date, 0 pages written');
return { ok: true, pagesWritten: 0 };
}
console.log(`[NFC] write-now: writing ${pages.length}/20 pages to ${readerName} (${surgical ? 'surgical' : 'full'})`);
// ── 3. Send pages to NFC utility process ────────────────────────────────────
const reqId = Date.now() + Math.random();
const result = await new Promise((resolve) => {
const timeout = setTimeout(() => {
_nfcWriteResolvers.delete(reqId);
resolve({ ok: false, error: 'Write timed out' });
}, 10000);
_nfcWriteResolvers.set(reqId, { resolve, timeout });
_nfcChild.postMessage({ type: 'write-now', readerName, reqId, pages });
});
if (result.ok) {
console.log(`[NFC] write-now: OK — ${result.pagesWritten} page(s) written`);
} else {
console.error('[NFC] write-now failed:', result.error);
}
return result;
});
// Build the page-write list from RAW bytes (pages start at `startPage`, 4 bytes
// each). Surgical: skip pages whose 4 bytes already match the chip's content.
function _pagesFromBytes(newBytes, oldBytes, startPage) {
const pages = [];
const n = Math.floor(newBytes.length / 4);
for (let i = 0; i < n; i++) {
const off = i * 4;
const np = newBytes.slice(off, off + 4);
if (oldBytes && off + 4 <= oldBytes.length && np.equals(oldBytes.slice(off, off + 4))) continue;
pages.push({ index: startPage + i, hexData: np.toString('hex') });