Repository navigation
Expand file tree
/
Copy pathpreload.js
More file actions
530 lines (471 loc) · 32.9 KB
/
Copy pathpreload.js
File metadata and controls
530 lines (471 loc) · 32.9 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
const { contextBridge, ipcRenderer } = require('electron');
// TD1S color sensor bridge (mirrors window.sensorAPI from TD1SxTigerTag-Electron)
contextBridge.exposeInMainWorld('td1s', {
onSensorData: (callback) => ipcRenderer.on('td1s-data', (_, data) => callback(data)),
onStatus: (callback) => ipcRenderer.on('td1s-status', (_, msg) => callback(msg)),
onLog: (callback) => ipcRenderer.on('td1s-log', (_, entry) => callback(entry)),
onClear: (callback) => ipcRenderer.on('td1s-clear', () => callback()),
onDevice: (callback) => ipcRenderer.on('td1s-device', (_, info) => callback(info)),
// Tell main process a TD1S-dependent UI opened / closed
need: () => ipcRenderer.send('td1s:need'),
release: () => ipcRenderer.send('td1s:release'),
});
// Local MCP server (AI assistants, read-only). The renderer registers the tool
// list, answers `mcp:call` with `mcp:result`, and drives the Settings card.
contextBridge.exposeInMainWorld('mcpBridge', {
registerTools: (tools) => ipcRenderer.send('mcp:register-tools', tools),
onCall: (cb) => ipcRenderer.on('mcp:call', (_, req) => cb(req)),
reply: (msg) => ipcRenderer.send('mcp:result', msg),
getConfig: () => ipcRenderer.invoke('mcp:get-config'),
setEnabled: (on) => ipcRenderer.invoke('mcp:set-enabled', on),
newToken: () => ipcRenderer.invoke('mcp:new-token'),
install: (target) => ipcRenderer.invoke('mcp:install', target),
setAccount: (uid) => ipcRenderer.invoke('mcp:set-account', uid),
// Hosted connector (Tiger Hub): list the online assistants, or cut one (id).
hostedGrants: (idToken, revokeId) => ipcRenderer.invoke('mcp:hosted-grants', idToken, revokeId),
});
// Prusa — PrusaLink HTTP (digest auth, path-allowlisted in main) + Buddy3D RTSP camera.
// Printer plugins — ONE generic bridge for every self-contained integration
// (renderer/printers/<id>/main.cjs registers `printer:<id>:<name>` handlers in
// main). Only that channel shape is forwarded; a plugin cannot reach any other
// IPC through it.
contextBridge.exposeInMainWorld('printerPlugin', {
invoke: (id, name, ...args) => {
if (!/^[a-z0-9-]+$/.test(String(id)) || !/^[a-zA-Z0-9-]+$/.test(String(name))) {
return Promise.reject(new Error('invalid plugin channel'));
}
return ipcRenderer.invoke(`printer:${id}:${name}`, ...args);
},
});
// Tiger Touch — its screen's picture, live frames and touches, all fetched by
// main (renderer/IoT/tigertouch draws the live view; the firmware serves no page).
// Printer camera captures — photos and source-quality videos, filed per printer
// (Pictures/Tiger Studio/<brand>_<MAC|serial|id>/), see main.js "Camera captures".
contextBridge.exposeInMainWorld('camCapture', {
saveImage: (printer, dataUrl) => ipcRenderer.invoke('cam:save-image', { printer, dataUrl }),
recUrlStart: (id, printer, url) => ipcRenderer.invoke('cam:rec-url-start', { id, printer, url }),
recTapStart: (id, printer, key) => ipcRenderer.invoke('cam:rec-tap-start', { id, printer, key }),
recFramesStart:(id, printer) => ipcRenderer.invoke('cam:rec-frames-start', { id, printer }),
recPollStart: (id, printer, url, everyMs) => ipcRenderer.invoke('cam:rec-poll-start', { id, printer, url, everyMs }),
fetchStill: (url) => ipcRenderer.invoke('cam:fetch-still', { url }),
probeStream: (url) => ipcRenderer.invoke('cam:probe-stream', { url }),
recFrame: (id, t, data) => ipcRenderer.invoke('cam:rec-frame', { id, t, data }),
recStop: (id) => ipcRenderer.invoke('cam:rec-stop', { id }),
saveRecording: (printer, data, ext) => ipcRenderer.invoke('cam:save-recording', { printer, data, ext }),
show: (file) => ipcRenderer.invoke('cam:show', { file }),
openFolder: (printer) => ipcRenderer.invoke('cam:open-folder', { printer }),
});
// TigerSpool — one picture of its screen (GET /screen.bmp, no code), fetched by main.
contextBridge.exposeInMainWorld('tigerspool', {
screenshot: ip => ipcRenderer.invoke('tigerspool:screenshot', { ip }),
});
// TigerScale V3 — one picture of its screen, read from its :81 live stream by main.
contextBridge.exposeInMainWorld('tigerscale', {
screenshot: (url, code, wake) => ipcRenderer.invoke('tigerscale:screenshot', { url, code, wake }),
});
contextBridge.exposeInMainWorld('tigertouch', {
screenshot: (ip, code) => ipcRenderer.invoke('tigertouch:screenshot', { ip, code }),
frame: (ip, code, since) => ipcRenderer.invoke('tigertouch:frame', { ip, code, since }),
touch: (ip, code, t) => ipcRenderer.invoke('tigertouch:touch', { ip, code, ...t }),
savePng: (dataUrl, name) => ipcRenderer.invoke('tigertouch:save-png', dataUrl, name),
});
// Demo mode — the printer simulator in main (services/demo, docs/DEMO-MODE.md).
contextBridge.exposeInMainWorld('demoMode', {
faces: () => ipcRenderer.invoke('demo:faces'),
allocate: (brand, records, variant, modelId) => ipcRenderer.invoke('demo:allocate', brand, records, variant, modelId),
sync: (records) => ipcRenderer.invoke('demo:sync', records),
state: (id) => ipcRenderer.invoke('demo:state', id),
set: (id, o) => ipcRenderer.invoke('demo:set', id, o),
lanHost: () => ipcRenderer.invoke('demo:lan-host'),
});
contextBridge.exposeInMainWorld('prusa', {
http: (opts) => ipcRenderer.invoke('prusa:http', opts),
probe: (host, timeout) => ipcRenderer.invoke('prusa:probe', host, timeout),
camStartRtsp: (key, host) => ipcRenderer.send('prusa:cam-start-rtsp', { key, host }),
camStopRtsp: (key) => ipcRenderer.send('prusa:cam-stop-rtsp', key),
onCamFrame: (cb) => ipcRenderer.on('prusa:cam-frame', (_, key, buf) => cb(key, buf)),
});
// External IP cameras (renderer/ext-cam) — ffmpeg in main, JPEG frames back.
contextBridge.exposeInMainWorld('extCam', {
start: (opts) => ipcRenderer.send('extcam:start', opts),
stop: (key) => ipcRenderer.send('extcam:stop', key),
test: (opts) => ipcRenderer.invoke('extcam:test', opts),
onFrame: (cb) => ipcRenderer.on('extcam:frame', (_, key, buf) => cb(key, buf)),
onStatus: (cb) => ipcRenderer.on('extcam:status', (_, key, status) => cb(key, status)),
});
contextBridge.exposeInMainWorld('bambulab', {
connect: (opts) => ipcRenderer.send('bambulab:connect', opts),
disconnect: (key) => ipcRenderer.send('bambulab:disconnect', key),
publish: (key, payload) => ipcRenderer.send('bambulab:publish', key, payload),
onMessage: (cb) => ipcRenderer.on('bambulab:message', (_, key, topic, data) => cb(key, topic, data)),
onStatus: (cb) => ipcRenderer.on('bambulab:status', (_, key, status) => cb(key, status)),
camStart: (opts) => ipcRenderer.send('bambulab:cam-start', opts),
camStop: (key) => ipcRenderer.send('bambulab:cam-stop', key),
camStartRtsp: (opts) => ipcRenderer.send('bambulab:cam-start-rtsp', opts),
camStopRtsp: (key) => ipcRenderer.send('bambulab:cam-stop-rtsp', key),
onCamFrame: (cb) => ipcRenderer.on('bambulab:cam-frame', (_, key, buf) => cb(key, buf)),
// Fetch the current print's model preview via FTPS + .3mf (PROTOCOL.md §11).
// opts = { ip, accessCode, fileHint, plateIdx } → { ok, dataUri } | { ok:false, error }
fetchThumbnail: (opts) => ipcRenderer.invoke('bambulab:fetch-thumbnail', opts),
/* Cloud mode (docs/bambu_connect_cloud.md). Telemetry deliberately arrives on
the LAN `onMessage` above — the cloud broker sends the same payload — so a
cloud printer needs no parser of its own. */
cloud: {
sendCode: (opts) => ipcRenderer.invoke('bambulab:cloud-send-code', opts),
login: (opts) => ipcRenderer.invoke('bambulab:cloud-login', opts),
tfa: (opts) => ipcRenderer.invoke('bambulab:cloud-tfa', opts),
uid: (opts) => ipcRenderer.invoke('bambulab:cloud-uid', opts),
bind: (opts) => ipcRenderer.invoke('bambulab:cloud-bind', opts),
deviceVersion: (opts) => ipcRenderer.invoke('bambulab:cloud-device-version', opts),
tasks: (opts) => ipcRenderer.invoke('bambulab:cloud-tasks', opts),
cover: (opts) => ipcRenderer.invoke('bambulab:cloud-cover', opts),
connect: (opts) => ipcRenderer.send('bambulab:cloud-connect', opts),
subscribe: (opts) => ipcRenderer.send('bambulab:cloud-subscribe', opts),
unsubscribe: (opts) => ipcRenderer.send('bambulab:cloud-unsubscribe', opts),
publish: (opts) => ipcRenderer.send('bambulab:cloud-publish', opts),
disconnect: () => ipcRenderer.send('bambulab:cloud-disconnect'),
onStatus: (cb) => ipcRenderer.on('bambulab:cloud-status', (_, status, region) => cb(status, region)),
},
});
contextBridge.exposeInMainWorld('elegoo', {
connect: (opts) => ipcRenderer.send('elegoo:connect', opts),
disconnect: (key) => ipcRenderer.send('elegoo:disconnect', key),
publish: (key, topic, payload) => ipcRenderer.send('elegoo:publish', key, topic, payload),
onMessage: (cb) => ipcRenderer.on('elegoo:message', (_, key, topic, data) => cb(key, topic, data)),
onStatus: (cb) => ipcRenderer.on('elegoo:status', (_, key, status) => cb(key, status)),
});
contextBridge.exposeInMainWorld('anycubic', {
// MQTT TLS to the printer's local broker (port 9883). opts =
// { key, ip, port?, modelId, deviceId, username, password }.
connect: (opts) => ipcRenderer.send('anycubic:connect', opts),
disconnect: (key) => ipcRenderer.send('anycubic:disconnect', key),
// payload = full request body (getInfo / setInfo / …). The command topic is
// built in main from the connect-time modelId/deviceId + the endpoint
// (e.g. "multiColorBox", "print" — defaults to multiColorBox).
publish: (key, payload, endpoint) => ipcRenderer.send('anycubic:publish', key, payload, endpoint),
onMessage: (cb) => ipcRenderer.on('anycubic:message', (_, key, topic, data) => cb(key, topic, data)),
onStatus: (cb) => ipcRenderer.on('anycubic:status', (_, key, status) => cb(key, status)),
// FLV camera (port 18088) — ffmpeg in main turns the stream into JPEG
// frames delivered on 'anycubic:cam-frame' (key, base64).
// The stream is on-demand: probe /flv first (live only after the printer
// starts capturing) and only then call camStart. Returns { ok, live }.
flvProbe: (ip, timeoutMs, url) => ipcRenderer.invoke('anycubic:flv-probe', ip, timeoutMs, url),
camStart: (opts) => ipcRenderer.send('anycubic:cam-start', opts),
camStop: (key) => ipcRenderer.send('anycubic:cam-stop', key),
onCamFrame: (cb) => ipcRenderer.on('anycubic:cam-frame', (_, key, b64) => cb(key, b64)),
// Fired when ffmpeg gives up (stream ended) so the UI drops to idle.
onCamEnded: (cb) => ipcRenderer.on('anycubic:cam-ended', (_, key) => cb(key)),
// One-click provisioning: read every paired LAN printer's durable broker
// credentials from AnycubicSlicerNext's on-disk config (slicer NOT needed
// running). Returns { ok, printers:[{ip,port,username,password,deviceId,
// modelId,name}], confPath } | { ok:false, error }.
readSlicerConfig: () => ipcRenderer.invoke('anycubic:read-slicer-config'),
// LAN scan helpers — port 18910 is the printer's plaintext discovery API
// (open only in LAN mode). tcpProbe = fast open check; httpInfo = GET /info
// device descriptor (modelId, modelName, deviceName, MAC) without CORS.
tcpProbe: (ip) => ipcRenderer.invoke('anycubic:tcp-probe', ip),
httpInfo: (ip, timeoutMs) => ipcRenderer.invoke('anycubic:http-info', ip, timeoutMs),
// ── Cloud-mode printers (reached through Anycubic's cloud) ─────────────
cloud: {
// Attach to a RUNNING bridge-mode slicer (CDP, port 9222) and read the
// workbench session token + email. We attach only — never launch the
// slicer. Returns { ok, token, email } | { ok:false, error }.
cdpToken: (port) => ipcRenderer.invoke('anycubic:cloud-cdp-token', port),
// Cross-platform login: open the official Anycubic cloud site in a window,
// let the user sign in, then read the workbench token (localStorage XX-Token).
webLogin: () => ipcRenderer.invoke('anycubic:cloud-web-login'),
// Signed REST: list the account's cloud printers / verify a token.
getPrinters: (token) => ipcRenderer.invoke('anycubic:cloud-get-printers', token),
// Per-printer live status (current nozzle/bed temps) via REST `parameter`.
printerInfo: (token, printerId) => ipcRenderer.invoke('anycubic:cloud-printer-info', { token, printerId }),
verify: (token) => ipcRenderer.invoke('anycubic:cloud-verify', token),
// Shared cloud-MQTT TLS identity → { caDerB64, clientCertPem, clientKeyPem },
// copied onto cloud printer docs for devices (TigerSpool) that dial the broker.
certs: () => ipcRenderer.invoke('anycubic:cloud-certs'),
// Send an ACE order (1206 = getInfo, 1211 = setSlot). The report comes
// back over cloud MQTT. opts = { token, orderId, printerId, data }.
sendOrder: (opts) => ipcRenderer.invoke('anycubic:cloud-send-order', opts),
// Open the cloud camera (order 1001) → Agora creds { appId, channel,
// rtcToken, clientUid, peerUid, encKey, encSalt, encMode }. opts = { token, printerId }.
cameraOpen: (opts) => ipcRenderer.invoke('anycubic:cloud-camera-open', opts),
// Cloud-uploaded files (PROTOCOL.md §9c). filesList → { ok, files:[…] };
// fileDelete → { ok }. opts = { token, page?, limit?, machineType?, printable? }
// / { token, fileId }. Printing reuses sendOrder (order 1) with the §9c payload.
filesList: (opts) => ipcRenderer.invoke('anycubic:cloud-files-list', opts),
fileDelete: (opts) => ipcRenderer.invoke('anycubic:cloud-file-delete', opts),
// Shared cloud-MQTT connection (one per signed-in user). connect once,
// then subscribe each printer; reports arrive on onMessage tagged with
// the renderer conn key passed to subscribe.
connect: (opts) => ipcRenderer.send('anycubic:cloud-connect', opts), // { email, token }
subscribe: (opts) => ipcRenderer.send('anycubic:cloud-subscribe', opts), // { connKey, machineType, key }
unsubscribe: (connKey) => ipcRenderer.send('anycubic:cloud-unsubscribe', connKey),
publish: (opts) => ipcRenderer.send('anycubic:cloud-publish', opts), // { machineType, key, endpoint, payload }
onMessage: (cb) => ipcRenderer.on('anycubic:cloud-message', (_, connKey, topic, data) => cb(connKey, topic, data)),
onStatus: (cb) => ipcRenderer.on('anycubic:cloud-status', (_, status) => cb(status)),
},
});
contextBridge.exposeInMainWorld('electronAPI', {
// True when running inside Electron
isElectron: true,
// Push the app-language labels for the native right-click menu (Cut/Copy/Paste/
// Select All) so it matches the in-app language, not the OS locale. Called on
// startup and on every language change.
setContextMenuLabels: (labels) => ipcRenderer.send('app:ctx-menu-labels', labels),
// Keep the native window chrome (title bar, traffic lights, system dialogs)
// in step with the in-app theme. Without this the frame stays whatever the
// main process forced at startup, so choosing Light leaves a black title bar
// sitting on top of a white UI.
setNativeTheme: (theme) => ipcRenderer.send('app:native-theme', theme),
// ── Deep links (tigertag://friend/<code>) ──────────────────────────────
// Main forwards a clicked deep link here. The renderer also calls
// deepLinkReady() once its handler is wired so main can flush a link that
// arrived before the UI finished loading (cold-start launch).
onDeepLink: (cb) => ipcRenderer.on('deep-link', (_, url) => cb(url)),
deepLinkReady: () => ipcRenderer.send('deep-link:ready'),
// ── USB scale (Dymo M-series) ──────────────────────────────────────────
// Connect/disconnect — callback({ connected, product })
onUsbScaleUpdate: (callback) =>
ipcRenderer.on('usb-scale-update', (_, data) => callback(data)),
// Decoded weight frame at ~1 Hz — callback({ status, grams })
// status: 'zero' | 'stable' | 'motion' | 'negative' | 'over' | 'fault'
onUsbScaleData: (callback) =>
ipcRenderer.on('usb-scale-data', (_, data) => callback(data)),
// Seed call — returns { connected, product } (events may predate the renderer)
getUsbScaleState: () =>
ipcRenderer.invoke('usb-scale:state'),
// Called when a card is scanned — callback(uid) — uid is uppercase hex
onRfid: (callback) =>
ipcRenderer.on('rfid-uid', (_, uid) => callback(uid)),
// Called when reader connects/disconnects — callback({ name, connected })
onReaderStatus: (callback) =>
ipcRenderer.on('reader-status', (_, status) => callback(status)),
// Called when a reader connects/disconnects (new unified event)
onRfidReaderUpdate: (callback) =>
ipcRenderer.on('rfid-reader-update', (_, data) => callback(data)),
// Called when a card appears/disappears on a reader — callback({ readerName, uid, rawUid })
onRfidCardPresent: (callback) =>
ipcRenderer.on('rfid-card-present', (_, data) => callback(data)),
// A chip that is not a TigerTag (no readable user pages) — callback({ readerName, uid, chip })
onRfidForeignChip: (callback) =>
ipcRenderer.on('rfid-foreign-chip', (_, data) => callback(data)),
// Full parsed tag from auto-read on card placement — callback(tagData)
onRfidTagScanned: (callback) =>
ipcRenderer.on('rfid-tag-scanned', (_, tagData) => callback(tagData)),
// On-demand read: returns { ok, uid, rawPagesHex, tagData } | { ok:false, error }
readRfidNow: (readerName) =>
ipcRenderer.invoke('rfid:read-now', readerName),
// On-demand tag write.
// opts = { readerName, cloudDoc, patch?, surgical? }
// cloudDoc : Firestore doc shape (id_brand, id_material, data1-data7, TD, …)
// patch : optional snake_case overrides { td_raw, custom_message, … }
// surgical : default true — only writes pages that differ (faster for small patches)
// Returns { ok, pagesWritten } | { ok:false, error }
writeRfidTag: (opts) =>
ipcRenderer.invoke('rfid:write-now', opts),
// Repair a TigerTag+ from its rfidList backup — writes the raw payload
// (pages 0x04-0x27) straight back, guarded to the chip whose UID matches.
// opts = { readerName, uid, hex }
// Returns { ok, pagesWritten, verified, mismatchPages } | { ok:false, error }
repairRfidTag: (opts) =>
ipcRenderer.invoke('rfid:repair', opts),
// Format / erase chips: wipe the user memory (pages 0x04-0x27) back to blank so
// they are ready for new data. Each target is UID-guarded.
// opts = { targets: [{ readerName, uid }] }
// Returns { ok, results: [{ readerName, uid, ok, pagesWritten?, verified?, error? }] }
formatRfidTag: (opts) =>
ipcRenderer.invoke('rfid:format', opts),
// Erase chips back to blank NDEF (SDK TigerTag.erase()) — drops them out of the
// TigerTag format into a plain blank NFC tag. Each target is UID-guarded.
// opts = { targets: [{ readerName, uid }] }
// Returns { ok, results: [{ readerName, uid, ok, pagesWritten?, verified?, error? }] }
eraseRfidTag: (opts) =>
ipcRenderer.invoke('rfid:erase', opts),
// Write an NDEF message (link, Wi-Fi, contact, e-mail, phone, SMS — services/ndef.js)
// onto one or more chips, UID-guarded, read-back verified.
// opts = { targets: [{ readerName, uid }], message: { type, … } }
// Returns { ok, results } (a chip too small: 'ndef-too-long' in its result)
writeNdefTag: (opts) =>
ipcRenderer.invoke('rfid:write-ndef', opts),
// Bytes that content takes on the chip: { ok, size } | { ok:false, error }
ndefSize: (message) =>
ipcRenderer.invoke('rfid:ndef-size', message),
// NDEF room of the chip on that reader, in bytes (144 NTAG213, 496 NTAG215, 872 NTAG216).
ndefCapacity: (readerName) =>
ipcRenderer.invoke('rfid:ndef-capacity', readerName),
// Cloud → chip encoding: program one or two blank RFID chips from a Cloud spool.
// Same payload (same timestamp) is written to every target reader.
// opts = { cloudDoc, targets: [{ readerName, uid }] }
// Returns { ok, results: [{ readerName, uid, ok, pagesWritten?, error? }] }
encodeCloudSpool: (opts) =>
ipcRenderer.invoke('rfid:encode-cloud', opts),
// Burn ONE chip with read-back verification (drives the guided encode modal).
// The caller picks a single chip-epoch `timestamp` and reuses it for both
// chips so they pair as twins, plus `tagCount` / `tagIndex` (rank from 1).
// opts = { cloudDoc, timestamp, readerName, tagCount, tagIndex }
// Returns { ok, verified, uid, pagesWritten, mismatchPages, error }
burnOneChip: (opts) =>
ipcRenderer.invoke('rfid:burn-one', opts),
// Fetch fresh TigerTag+ product data from the catalogue API.
// rawDoc must be the Firestore doc shape (needs id_product + id_tigertag).
// Returns { ok: true, api } | { ok: false, error }
refreshApiData: (rawDoc) =>
ipcRenderer.invoke('rfid:refresh-api', rawDoc),
// Validate a product_id against the TigerTag catalogue (no chip required).
// Returns { ok: true, api } | { ok: false, error }
lookupProduct: (productId) =>
ipcRenderer.invoke('rfid:lookup-product', productId),
// Pull the WHOLE product catalogue (all pages) so the renderer can cache it
// and search it in memory. Only called to (re)build that local cache.
// Returns { ok: true, items, itemsTotal } | { ok: false, error }
fetchCatalogAll: () =>
ipcRenderer.invoke('catalog:fetch-all'),
// Report a physical-chip event (read / write / reset / recycle) to the TigerTag
// Xano analytics table — same table and shape as the mobile app. `send`, not
// `invoke`: fire-and-forget by construction, so it can never block or slow a
// chip action, and a dropped event is simply lost (see main.js for why the
// request is made from the main process rather than the renderer).
sendTagAnalytics: (payload) =>
ipcRenderer.send('analytics:tag', payload),
// Called when an app update is available or ready to install
onUpdateStatus: (callback) =>
ipcRenderer.on('update-status', (_, info) => callback(info)),
// Ask main process to install the downloaded update and restart
installUpdate: () => ipcRenderer.send('install-update'),
// ── Auto-update preference (ON by default) ─────────────────────────────
// Renderer toggles auto-update at runtime. Main process honours it on
// startup and on subsequent manual checks.
setAutoUpdate: (enabled) => ipcRenderer.send('update:set-auto', !!enabled),
// Manually trigger an update check (used by "Check for updates" button).
// Resolves with { ok: true } or { ok: false, error: "…" }.
checkForUpdates: () => ipcRenderer.invoke('update:check-now'),
// ── Google sign-in via loopback OAuth (RFC 8252 + PKCE) ────────────────
// Opens the system browser (Safari on macOS, default on Win/Linux) so the
// user can authenticate with Touch ID / passkey / hardware key NATIVELY.
// The Chromium popup spawned by firebase.auth().signInWithPopup() can't
// talk to the macOS authd daemon, which is why "Use your passkey" was
// inert. Resolves with `{ ok, idToken, accessToken }` or `{ ok:false, error }`.
signInWithGoogleLoopback: () => ipcRenderer.invoke('auth:google-loopback'),
// ── UID migration in-flight signal ─────────────────────────────────────
// Renderer announces when the inventory migration sweep starts and
// finishes. Main uses this to intercept Cmd+Q / window close and prompt
// the user before letting them quit mid-migration (which would leave
// a partial state, even though the next launch would resume cleanly).
setMigrationInFlight: (inFlight) => ipcRenderer.send('migration:set-in-flight', !!inFlight),
// ── App info (version / platform — used by the diagnostic report) ──────
getAppInfo: () => ipcRenderer.invoke('app:info'),
// Screenshot of this window for the in-app bug report (JPEG data URL or null).
captureWindow: () => ipcRenderer.invoke('report:capture'),
// Open a URL in the default system application (e.g. VLC for rtsp://).
openExternal: (url) => ipcRenderer.send('shell:open-external', url),
// ── Detached camera wall window ──────────────────────────────────────────
// Opens (or focuses) a standalone window displaying all online printer cameras.
// cameras: CamDescriptor[] — built by inventory.js _serializeCamerasForDetach()
openCamWindow: (cameras) => ipcRenderer.invoke('cam:open-detached', cameras),
// Show native Save dialog and stream a timelapse video from the printer to disk.
downloadTimelapse: (url, filename) => ipcRenderer.invoke('timelapse:download', url, filename),
// Show native Save dialog and download an image (e.g. a QR code) to disk.
downloadImage: (url, filename) => ipcRenderer.invoke('image:download', url, filename),
// Absolute path to renderer/ dir — used to build file:// preload paths
// for <webview> elements (e.g. Creality camera preload script).
getRendererPath: () => ipcRenderer.invoke('app:renderer-path'),
// ── Local network — list active /24 LAN subnets (e.g. "192.168.1") so
// the renderer can scan them for Snapmaker / Moonraker printers. Falls
// back to common defaults in the renderer if this returns nothing.
getLocalSubnets: () => ipcRenderer.invoke('net:get-local-subnets'),
// MAC behind a LAN IP (OS neighbour cache) — null off the local subnet.
arpMac: (ip) => ipcRenderer.invoke('net:arp-mac', ip),
// ── mDNS — browse `_snapmaker._tcp.local.` for instant Snapmaker
// discovery. Returns { ok, candidates: [{ name, host, port, fqdn,
// addresses: [...], txt: { ip, machine_type, device_name, sn,
// version, link_mode, ... } }] }. Empty array means either no
// Snapmaker on the LAN OR mDNS multicast is filtered (firewall /
// multi-VLAN without reflector) — renderer falls back to port-scan.
mdnsBrowseSnapmaker: () => ipcRenderer.invoke('mdns:browse-snapmaker'),
// ── Snapmaker HTTP GET — bridge through main process to bypass CORS.
// Direct fetch() from the renderer to http://<local-ip>:7125 is cross-
// origin (page origin is http://localhost:<port>) and blocked by
// Chromium. Node's fetch() in main is not subject to CORS, so it goes
// through cleanly — identical to what the Flutter http.get() calls do.
// timeoutMs is optional (main-process default is 3500 ms).
// Returns { ok, status, json } | { ok: false, error }.
snapHttpGet: (url, timeoutMs) => ipcRenderer.invoke('snap:http-get', url, timeoutMs),
// ── FlashForge HTTP — bridge through main process to bypass CORS.
// The FlashForge firmware (port 8898 /detail + /control) doesn't handle
// CORS preflight, so direct fetch() from the renderer fails. This IPC
// lets the renderer issue the same POST through Node's fetch (no CORS).
// timeoutMs is optional (default 4000 ms for live polling; 350 ms for scan).
// Returns the parsed JSON or { code:-1|-2, message } error envelopes.
ffgHttpPost: (url, body, timeoutMs) => ipcRenderer.invoke('ffg:http-post', url, body, timeoutMs),
// ── FlashForge UDP Multicast discovery (Adventurer 4 era).
// Sends "Hello World!" to 225.0.0.9:19000, collects IP+name replies 2.5 s.
// Returns { ok, candidates: [{ ip, printerName }] }.
ffgMulticastDiscover: () => ipcRenderer.invoke('ffg:multicast-discover'),
// ── FlashForge TCP probe — port 8899, M115 identity fallback.
// Connects to ip:8899, sends ~M115\r\n, parses response (700 ms timeout).
// Returns { ok, fields: { machineModel, machineName, firmware, serialNumber, macAddress } }.
ffgTcpProbe: (ip) => ipcRenderer.invoke('ffg:tcp-probe', ip),
// ── FlashForge UDP identity probe — port 19000.
// Sends a UDP datagram to ip:19000; modern models (5M, AD5X, Creator 5/Pro)
// reply with model + serial WITHOUT credentials — the same way the FlashForge
// slicer identifies a printer before asking only for the access code.
// Returns { ok, machineModel, serialNumber } (serial stored without the SN prefix).
ffgUdpProbe: (ip) => ipcRenderer.invoke('ffg:udp-probe', ip),
// ── Creality TCP probe — port 9999 open/closed fast filter.
// Connects to ip:9999 (650 ms timeout) and reports whether the port is
// open. The renderer then runs the WebSocket JSON handshake on each open
// host to confirm it's a Creality printer (isCrealityLike). Returns
// { ok: true } when the port accepts a connection, { ok: false, error } otherwise.
creTcpProbe: (ip) => ipcRenderer.invoke('cre:tcp-probe', ip),
// ── Creality Moonraker HTTP (port 7125) — gcode/script, print-start, delete.
// The K-series runs Klipper+Moonraker, which sends NO CORS headers, so a
// renderer fetch() is blocked (preflight 405 / unreadable response). Node's
// fetch() in main is CORS-exempt — same reason as snapHttpGet. method is
// GET|POST|DELETE; query is an optional object appended as a query string.
// Returns { ok, status, json } | { ok:false, status:0, error }.
creHttp: (ip, method, path, query, timeoutMs) =>
ipcRenderer.invoke('cre:http', ip, method, path, query, timeoutMs),
// ── Bambu Lab SSDP discovery — multicast 239.255.255.250:1900.
// Sends an M-SEARCH (twice, 120 ms apart) and listens 4 s for replies +
// unsolicited NOTIFYs. Returns { ok, candidates: [{ip, serial, model, name,
// firmware, source:'ssdp'}] } — duplicates already merged by serial.
bambulabSsdpDiscover: () => ipcRenderer.invoke('bambu:ssdp-discover'),
// ── Bambu Lab TLS cert sniff on :8883 — per-IP brand confirmation.
// Connects TLS to ip:8883, reads the peer certificate, and reports whether
// the subject/issuer identifies it as a Bambu printer. Used by manual
// "Add by IP". Returns { ok, serial?, raw:{subject,issuer} }.
bambulabTlsProbe: (ip) => ipcRenderer.invoke('bambu:tls-probe', ip),
// ── Elegoo UDP discovery — unicast spray on :52700 over /24 prefixes.
// Sends {"id":0,"method":7000} to every host in the prefix list, listens
// 2.4 s for JSON replies. Returns { ok, candidates: [{ip, sn, machineModel,
// hostName, source:'udp'}] }.
elegooUdpDiscover: (prefixes) => ipcRenderer.invoke('elegoo:udp-discover', prefixes),
// ── Elegoo UDP probe — targeted single-IP discovery (manual "Add by IP").
// Two sends 60 ms apart, 1.4 s listen. Returns { ok, candidate? }.
elegooUdpProbe: (ip) => ipcRenderer.invoke('elegoo:udp-probe', ip),
// SDCP printers (Centauri Carbon 1): M99999 on UDP 3000 — sweep + single IP.
elegooSdcpDiscover: (prefixes) => ipcRenderer.invoke('elegoo:sdcp-discover', prefixes),
elegooSdcpProbe: (ip) => ipcRenderer.invoke('elegoo:sdcp-probe', ip),
// ── Image cache (main-process side) ─────────────────────────────────────
imgGet: (url) => ipcRenderer.invoke('img:get', url),
imgPrune: (keep) => ipcRenderer.invoke('img:prune', keep),
// ── Local DB (main-process side) ─────────────────────────────────────────
db: {
getLookups: () => ipcRenderer.invoke('db:getLookups'),
getLabel: (category, id) => ipcRenderer.invoke('db:getLabel', category, id),
getMaterialLabel: (id) => ipcRenderer.invoke('db:getMaterialLabel', id),
getBambuMaterials: () => ipcRenderer.invoke('db:getBambuMaterials'),
getPublicKeyForId: (id) => ipcRenderer.invoke('db:getPublicKeyForId', id),
getAllLastUpdateTimestamps: () => ipcRenderer.invoke('db:getAllLastUpdateTimestamps'),
isUpdateAvailable: () => ipcRenderer.invoke('db:isUpdateAvailable'),
updateIfNeeded: () => ipcRenderer.invoke('db:updateIfNeeded'),
downloadAndSaveLatestData: () => ipcRenderer.invoke('db:downloadAndSaveLatestData'),
},
});
// ── Splash gate ──────────────────────────────────────────────────────────
// The renderer calls window.studio.ready() once its FIRST usable frame is
// painted (cached avatar + inventory hydrated from localStorage). Main then
// swaps the hidden main window in for the splash. Main also has a hard
// fallback timeout, so a missed signal never leaves the app invisible.
contextBridge.exposeInMainWorld('studio', {
ready: () => ipcRenderer.send('studio:ready'),
});