Skip to content

Commit e1fef1e

Browse files
committed
feat: Creator 5 Cloud+LAN firmware in the app, Anycubic cloud TLS identity for TigerSpool, fixes
- FlashForge Creator 5 / 5 Pro: the official FlashForge x TigerSystem "Cloud + LAN" firmware. Resolver creator5-fw.js reads the public repo's latest release (24 h cache + ETag, build-time fallback, per-model file). The printer's Info window shows a firmware block (up to date / update available, download for this model, USB install guide, OTA warning); a local notification fires when a connected Creator 5 is behind. - Anycubic cloud printers: the doc also carries Anycubic's shared cloud-MQTT TLS identity (acuCloudCaDerB64 / acuCloudClientCertPem / acuCloudClientKeyPem), served by a new anycubic:cloud-certs IPC, so a TigerSpool can dial the broker itself. Written on add, on token refresh, and as a per-doc catch-up on every printers snapshot. Never on LAN docs, never logged. - FlashForge add form: the password field is labelled "Printer ID" and every hint follows the connection tutorial's paths (#29). - LAN scan: the add button reads just "Add". - Spool detail card: a rebuild after an edit (e.g. changing the master spool) keeps the scroll position instead of jumping to the top. - Snapmaker Paxx firmware: offline fallback bumped to v1.6.0-paxx12-22 (#21).
1 parent c51f944 commit e1fef1e

22 files changed

Lines changed: 567 additions & 128 deletions

‎docs/anycubic-send-to-printer.md‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ account's cloud printers; pick the online one to add it.
7474

7575
Printers are stored as Firestore docs under `users/{uid}/printers/anycubic/devices`.
7676
LAN docs carry `{ ip, acuModelId, deviceId, username, password }`; cloud docs carry
77-
`{ mode:"cloud", cloudPrinterId, machineType, key, cloudToken, cloudEmail }` (doc id
77+
`{ mode:"cloud", cloudPrinterId, machineType, key, cloudToken, cloudEmail }` plus the
78+
shared TLS identity (see *Cloud provisioning*) (doc id
7879
`cloud_<id>`, so re-provisioning upserts and refreshes the token). They sync across
7980
your devices like every other printer.
8081

@@ -128,6 +129,21 @@ handles this:
128129

129130
LAN printers are unaffected by any of this.
130131

132+
### TLS identity on the doc — for devices that dial the broker themselves
133+
Cloud docs also carry `acuCloudCaDerB64`, `acuCloudClientCertPem` and
134+
`acuCloudClientKeyPem`, for devices (TigerSpool) that connect to the cloud MQTT broker
135+
(`mqtt-universe.anycubic.com:8883`) themselves, the way the app's main process does.
136+
They are Anycubic's **shared** TLS identity — the CA certificate (DER, base64 without
137+
whitespace) and the client certificate + key (PEM, newlines kept) — copied verbatim from
138+
`services/anycubicCloudCerts.js` through the `anycubic:cloud-certs` IPC. With them, the
139+
doc holds everything such a device needs: `key`, `machineType`, `cloudEmail`,
140+
`cloudToken`, and the TLS identity.
141+
142+
- Written when a cloud printer is added, again on every token refresh, and once at
143+
startup for any cloud doc that predates them (catch-up).
144+
- **Never** written on LAN docs, and neither these values nor `cloudToken` are ever
145+
logged.
146+
131147
> **Note — one cloud connection at a time.** The cloud MQTT broker enforces a fixed
132148
> client id per account, so the app and the slicer's Workbench can't both hold a cloud
133149
> connection at once (they'd kick each other). Setting a slot is a REST call and works

‎main.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4491,6 +4491,16 @@ let _ffmpegBin = null;
44914491
});
44924492

44934493
// Verify a token (and fetch the email) via userInfo. { ok, email } | { ok:false }.
4494+
/* Anycubic's shared cloud-MQTT TLS identity (CA + the client cert/key every
4495+
Anycubic slicer ships), for DEVICES that connect to the broker themselves —
4496+
a TigerSpool reads them off the printer's Firestore doc. Handed over as-is,
4497+
never logged. */
4498+
ipcMain.handle('anycubic:cloud-certs', async () => ({
4499+
caDerB64: certs.CA_DER_B64,
4500+
clientCertPem: certs.CLIENT_CERT_PEM,
4501+
clientKeyPem: certs.CLIENT_KEY_PEM,
4502+
}));
4503+
44944504
ipcMain.handle('anycubic:cloud-verify', async (_evt, token) => {
44954505
try {
44964506
const r = await _cloudFetch(token, 'GET', '/user/profile/userInfo', null);

‎preload.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ contextBridge.exposeInMainWorld('anycubic', {
101101
// Per-printer live status (current nozzle/bed temps) via REST `parameter`.
102102
printerInfo: (token, printerId) => ipcRenderer.invoke('anycubic:cloud-printer-info', { token, printerId }),
103103
verify: (token) => ipcRenderer.invoke('anycubic:cloud-verify', token),
104+
// Shared cloud-MQTT TLS identity → { caDerB64, clientCertPem, clientKeyPem },
105+
// copied onto cloud printer docs for devices (TigerSpool) that dial the broker.
106+
certs: () => ipcRenderer.invoke('anycubic:cloud-certs'),
104107
// Send an ACE order (1206 = getInfo, 1211 = setSlot). The report comes
105108
// back over cloud MQTT. opts = { token, orderId, printerId, data }.
106109
sendOrder: (opts) => ipcRenderer.invoke('anycubic:cloud-send-order', opts),

‎renderer/CODEMAP.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ Per-brand scan/manual flows live in `printers/<brand>/add-flow.js`; `inventory.j
324324

325325
---
326326

327-
## Racks CRUD + slots (L27594-31341)
327+
## Racks CRUD + slots (L27594-31560)
328328
| L | What | Anchors |
329329
|---|---|---|
330330
| 12429-12563 | Rack create / update / delete / empty + orphan ref cleanup | `createRack`, `updateRack`, `deleteRack`, `emptyRack` |
@@ -336,7 +336,7 @@ Per-brand scan/manual flows live in `printers/<brand>/add-flow.js`; `inventory.j
336336

337337
---
338338

339-
## Storage view render + DnD (L29928-33111)
339+
## Storage view render + DnD (L29928-33300)
340340
| L | What | Anchors |
341341
|---|---|---|
342342
| 13283-13922 | **`renderRackView()`** — biggest function in the file: stats bar + filter chips, two-column layout, masonry, kebab menus, live search, read-only friend mode, rack reorder DnD | `renderRackView` |
@@ -373,7 +373,7 @@ Per-brand scan/manual flows live in `printers/<brand>/add-flow.js`; `inventory.j
373373

374374
---
375375

376-
## Custom avatar (L33504-35481)
376+
## Custom avatar (L33504-35700)
377377
| L | What | Anchors |
378378
|---|---|---|
379379
| 15637-15811 | File pick, image decode, alpha detection, resize to blob, upload, remove | `uploadCustomAvatar`, `removeCustomAvatar` |

‎renderer/css/50-snapmaker.css‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2089,6 +2089,20 @@
20892089
.ffg-info-card { width: min(420px, 92vw); }
20902090
.ffg-info-body { padding: 4px 18px 18px; max-height: 70vh; overflow-y: auto; }
20912091
.ffg-info-section { margin-top: 14px; }
2092+
/* Creator 5 / 5 Pro "Cloud + LAN" firmware block (flashforge/creator5-fw.js). */
2093+
.ffg-fw-status {
2094+
font-size: 13px; line-height: 1.45; color: var(--text);
2095+
padding: 8px 10px; border-radius: 8px;
2096+
background: var(--surface-2); border: 1px solid var(--border);
2097+
}
2098+
.ffg-fw-status--current { border-color: color-mix(in srgb, var(--success) 45%, var(--border)); }
2099+
.ffg-fw-status--update {
2100+
border-color: color-mix(in srgb, var(--primary) 55%, var(--border));
2101+
background: color-mix(in srgb, var(--primary) 10%, var(--surface));
2102+
}
2103+
.ffg-fw-actions { display: flex; gap: 8px; margin-top: 10px; }
2104+
.ffg-fw-actions .ffg-fw-btn { flex: 1 1 0; min-width: 0; justify-content: center; }
2105+
.ffg-fw-note { margin-top: 8px; font-size: 11.5px; line-height: 1.4; color: var(--muted); }
20922106
.ffg-info-section-title {
20932107
font-size: 12px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
20942108
color: var(--muted); margin-bottom: 6px;

‎renderer/inventory.js‎

Lines changed: 111 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
openFlashforgeFiles, closeFlashforgeFiles,
5555
} from './printers/flashforge/index.js';
5656
import { renderFfgCamBanner, renderFfgCamWallBanner, ffgRefreshCamBanner, ffgCamBaseUrl } from './printers/flashforge/widget_camera.js';
57+
import { c5fwIsCreator5, c5fwEnsureLatest, c5fwStatus, c5fwLabel } from './printers/flashforge/creator5-fw.js';
5758
import { ffgMuxStart, ffgMuxStop, ffgMuxStopAll, ffgMuxRestart, ffgMuxRegister, ffgMuxUnregister } from './printers/flashforge/cam_mux.js';
5859
import { renderSnapCamBanner } from './printers/snapmaker/widget_camera.js';
5960
import { openSnapAddFlow } from './printers/snapmaker/add-flow.js';
@@ -16992,7 +16993,10 @@ import { elgFanStep } from './printers/elegoo/widget_control.js';
1699216993
function _rebuildDetailKeepVideo(opts) {
1699316994
const keepV = $("detailPanel")?.querySelector(".panel-video-player video") || null;
1699416995
const vState = keepV ? { node: keepV, time: keepV.currentTime, paused: keepV.paused } : null;
16995-
openDetail(state.selected, opts);
16996+
/* Keep the scroll too: every caller refreshes the spool ALREADY on screen
16997+
(a container change, a structural diff) — without it the card jumped
16998+
back to the top on each save made from further down. */
16999+
openDetail(state.selected, { preserveScroll: true, ...opts });
1699617000
if (!vState) return;
1699717001
const newV = $("detailPanel")?.querySelector(".panel-video-player video");
1699817002
if (newV && newV.getAttribute("src") === vState.node.getAttribute("src")) {
@@ -21950,6 +21954,8 @@ import { elgFanStep } from './printers/elegoo/widget_control.js';
2195021954
return String(a.printerName || "").localeCompare(String(b.printerName || ""));
2195121955
});
2195221956
state.printers = all;
21957+
// Anycubic cloud printers added before the TLS-identity fields existed.
21958+
_backfillAnycubicCloudCerts().catch(e => console.warn("[anycubic] cloud cert backfill:", e?.message));
2195321959
// The machines have just arrived — their units can now be recorded.
2195421960
syncAllPrinterUnits();
2195521961
scheduleStudioStateRecord(); // re-arm deferred telemetry (printer count changed)
@@ -26540,6 +26546,11 @@ import { elgFanStep } from './printers/elegoo/widget_control.js';
2654026546
// When a brand reaches "connected", auto-clear forced-offline so the
2654126547
// badge and panel return to their live state correctly.
2654226548
if (status === "connected" && key) _ppForcedOfflineKeys.delete(key);
26549+
// A FlashForge online has read its firmware version — a Creator 5 may be
26550+
// due the Cloud + LAN firmware. Slight delay: `/detail` lands after connect.
26551+
if (status === "connected" && String(key || "").startsWith("flashforge:")) {
26552+
setTimeout(() => { _maybeNotifyFfgFirmware().catch(() => {}); }, 4000);
26553+
}
2654326554
if (typeof refreshOpenPrinterDetail === "function") refreshOpenPrinterDetail();
2654426555
// In cam mode, a connection/disconnection must refresh the wall so new
2654526556
// feeds appear (or stale cards disappear) without the user leaving the view.
@@ -26762,10 +26773,50 @@ import { elgFanStep } from './printers/elegoo/widget_control.js';
2676226773
}
2676326774
};
2676426775

26776+
/* The shared Anycubic cloud-MQTT TLS identity, as Firestore fields. Copied
26777+
onto every CLOUD printer doc (never a LAN one) so a device that dials
26778+
mqtt-universe.anycubic.com itself — a TigerSpool — finds everything on the
26779+
doc it already reads (key, machineType, cloudEmail, cloudToken). Fetched
26780+
once per session from the main process; values are never logged. */
26781+
let _acuCertFieldsP = null;
26782+
function _acuCloudCertFields() {
26783+
if (!_acuCertFieldsP) {
26784+
_acuCertFieldsP = Promise.resolve(window.anycubic?.cloud?.certs?.())
26785+
.then(c => (c && c.caDerB64 && c.clientCertPem && c.clientKeyPem) ? {
26786+
acuCloudCaDerB64: String(c.caDerB64),
26787+
acuCloudClientCertPem: String(c.clientCertPem),
26788+
acuCloudClientKeyPem: String(c.clientKeyPem),
26789+
} : null)
26790+
.catch(() => null);
26791+
_acuCertFieldsP.then(f => { if (!f) _acuCertFieldsP = null; }); // retry next time
26792+
}
26793+
return _acuCertFieldsP;
26794+
}
26795+
// Catch-up for cloud printers added before the fields existed. Runs on EVERY
26796+
// printers snapshot — the brands arrive one subcollection at a time, so the
26797+
// first snapshot may not hold the Anycubic docs yet — and tries each doc once
26798+
// per session (a failed write is not hammered on every later snapshot).
26799+
const _acuCertBackfillTried = new Set();
26800+
async function _backfillAnycubicCloudCerts() {
26801+
const uid = state.activeAccountId;
26802+
if (!uid) return;
26803+
const missing = (state.printers || []).filter(p => p.brand === "anycubic" && p.mode === "cloud"
26804+
&& !_acuCertBackfillTried.has(p.id)
26805+
&& !(p.acuCloudCaDerB64 && p.acuCloudClientCertPem && p.acuCloudClientKeyPem));
26806+
if (!missing.length) return;
26807+
const fields = await _acuCloudCertFields();
26808+
if (!fields) return; // main process not ready — next snapshot retries
26809+
missing.forEach(p => _acuCertBackfillTried.add(p.id));
26810+
const devices = fbDb(uid).collection("users").doc(uid).collection("printers").doc("anycubic").collection("devices");
26811+
await Promise.all(missing.map(p => devices.doc(p.id).set(fields, { merge: true })
26812+
.catch(e => console.warn("[anycubic] cloud cert backfill failed:", e?.code)))); // code only — never the values
26813+
}
26814+
2676526815
_printerCtx.addAnycubicCloudPrinter = async (rec) => {
2676626816
const uid = state.activeAccountId;
2676726817
if (!uid) return { ok: false, error: "no-account" };
2676826818
try {
26819+
const certFields = (await _acuCloudCertFields()) || {};
2676926820
const ref = fbDb(uid).collection("users").doc(uid)
2677026821
.collection("printers").doc("anycubic")
2677126822
.collection("devices").doc("cloud_" + rec.cloudPrinterId);
@@ -26784,6 +26835,7 @@ import { elgFanStep } from './printers/elegoo/widget_control.js';
2678426835
printerModelId: String(rec.printerModelId || "0"),
2678526836
isActive: existing ? !!existing.isActive : false,
2678626837
sortIndex: existing && Number.isFinite(existing.sortIndex) ? existing.sortIndex : state.printers.length,
26838+
...certFields, // acuCloudCaDerB64 / acuCloudClientCertPem / acuCloudClientKeyPem
2678726839
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
2678826840
}, { merge: true });
2678926841
return { ok: true, id: ref.id };
@@ -26801,12 +26853,13 @@ import { elgFanStep } from './printers/elegoo/widget_control.js';
2680126853
if (!uid || !token) return { ok: false };
2680226854
try {
2680326855
const db = fbDb(uid);
26856+
const certFields = (await _acuCloudCertFields()) || {};
2680426857
const cloudDocs = (state.printers || []).filter(p => p.brand === "anycubic" && p.mode === "cloud");
2680526858
await Promise.all(cloudDocs.map(p => {
2680626859
p.cloudToken = token; if (email) p.cloudEmail = email; // keep state in sync now
2680726860
return db.collection("users").doc(uid).collection("printers").doc("anycubic")
2680826861
.collection("devices").doc(p.id)
26809-
.set({ cloudToken: token, ...(email ? { cloudEmail: email } : {}) }, { merge: true });
26862+
.set({ cloudToken: token, ...(email ? { cloudEmail: email } : {}), ...certFields }, { merge: true });
2681026863
}));
2681126864
return { ok: true, n: cloudDocs.length };
2681226865
} catch (e) {
@@ -34588,6 +34641,54 @@ import { elgFanStep } from './printers/elegoo/widget_control.js';
3458834641
if (n?.tag) localStorage.setItem(PAXX_DISMISSED_LS_KEY, n.tag);
3458934642
_clearLocalNotif("paxx");
3459034643
}
34644+
// ── FlashForge Creator 5 "Cloud + LAN" firmware notice ────────────────────
34645+
// Same contract as the paxx notice above (card click / "Remind me later" =
34646+
// this session only, ✕ = this release never again), for the firmware
34647+
// FlashForge built with TigerTag (printers/flashforge/creator5-fw.js). The
34648+
// installed version comes from the LIVE connection (`/detail` →
34649+
// firmwareVersion), so it is checked when a FlashForge comes online rather
34650+
// than on the Firestore snapshot, where nothing has been read yet.
34651+
const FFGFW_DISMISSED_LS_KEY = "tigertag.ffgCreator5Fw.dismissedTag";
34652+
let _ffgFwSessionSnoozeTag = null;
34653+
async function _maybeNotifyFfgFirmware() {
34654+
const creators = (state.printers || []).filter(p => p.brand === "flashforge" && c5fwIsCreator5(p));
34655+
if (!creators.length) return;
34656+
const rec = await c5fwEnsureLatest();
34657+
if (!rec?.tag) return;
34658+
const outdated = creators.find(p =>
34659+
c5fwStatus(ffgGetConn(ffgKey(p))?.data?.info?.firmware, rec.tag) === "update");
34660+
if (!outdated) return;
34661+
if (localStorage.getItem(FFGFW_DISMISSED_LS_KEY) === rec.tag) return;
34662+
if (_ffgFwSessionSnoozeTag === rec.tag) return;
34663+
const pName = (outdated.printerName || "").trim() || PRINTER_BRAND_META.flashforge?.label || "FlashForge";
34664+
_setLocalNotif({
34665+
id: "ffgfw", icon: "download", action: "ffgfw", brand: "flashforge",
34666+
title: t("notifFfgFwTitle", { name: pName }),
34667+
text: t("notifFfgFwText", { version: c5fwLabel(rec.tag) }),
34668+
tag: rec.tag, printerId: outdated.id, printerName: pName,
34669+
});
34670+
}
34671+
function _ffgFwSnooze() {
34672+
const n = (state.localNotifications || []).find(x => x.id === "ffgfw");
34673+
if (n?.tag) _ffgFwSessionSnoozeTag = n.tag;
34674+
_clearLocalNotif("ffgfw");
34675+
return n;
34676+
}
34677+
// Card click → the machine's page and its Info window, where the firmware
34678+
// block hands over the right file and the USB walkthrough.
34679+
function _openFfgFwPrinter() {
34680+
const n = _ffgFwSnooze();
34681+
const printer = (state.printers || []).find(p => p.id === n?.printerId && p.brand === "flashforge");
34682+
if (!printer) return;
34683+
openPrinterDetail("flashforge", printer.id);
34684+
openFlashforgeInfo(printer);
34685+
}
34686+
function _dismissFfgFwNotif() {
34687+
const n = (state.localNotifications || []).find(x => x.id === "ffgfw");
34688+
if (n?.tag) localStorage.setItem(FFGFW_DISMISSED_LS_KEY, n.tag);
34689+
_clearLocalNotif("ffgfw");
34690+
}
34691+
3459134692

3459234693
// Remove a local notification by id (e.g. the avatar nudge once a photo is set).
3459334694
function _clearLocalNotif(id) {
@@ -34760,7 +34861,8 @@ import { elgFanStep } from './printers/elegoo/widget_control.js';
3476034861
const localHtml = locals.map(n => {
3476134862
// Community nudges + avatar: the whole row IS the call-to-action (no button).
3476234863
const isCommunity = !!COMMUNITY[n.action];
34763-
const clickable = isCommunity || n.action === "avatar" || n.action === "paxx" || n.action === "lowstock";
34864+
const isFw = n.action === "paxx" || n.action === "ffgfw"; // firmware notices: later + ✕
34865+
const clickable = isCommunity || n.action === "avatar" || isFw || n.action === "lowstock";
3476434866
const brandIc = isCommunity ? ` notif-ic--${n.action}` // branded square icon
3476534867
: (n.action === "lowstock" ? " notif-ic--lowstock" : ""); // amber alert icon
3476634868
// A notice originating from a printer speaks WITH its brand logo (the
@@ -34780,10 +34882,10 @@ import { elgFanStep } from './printers/elegoo/widget_control.js';
3478034882
<div class="fp-friend-main">
3478134883
${n.title ? `<div class="notif-title">${esc(n.title)}</div>` : ""}
3478234884
<div class="notif-text">${esc(n.text)}${n.version ? ` (v${esc(n.version)})` : ""}</div>
34783-
${n.action === "paxx" ? `<button type="button" class="primary sm notif-later" title="${esc(t("notifRemindLater"))}"><span class="icon icon-bell icon-12"></span>${esc(t("notifRemindLater"))}</button>` : ""}
34885+
${isFw ? `<button type="button" class="primary sm notif-later" title="${esc(t("notifRemindLater"))}"><span class="icon icon-bell icon-12"></span>${esc(t("notifRemindLater"))}</button>` : ""}
3478434886
</div>
3478534887
${n.action === "restart" ? `<button class="primary sm notif-restart">${esc(t("btnRestartUpdate"))}</button>` : ""}
34786-
${n.action === "paxx" ? `<button type="button" class="fp-friend-btn notif-dismiss notif-dismiss-paxx" title="${esc(t("cancelLabel"))}"><span class="icon icon-close icon-13"></span></button>` : ""}
34888+
${isFw ? `<button type="button" class="fp-friend-btn notif-dismiss notif-dismiss-paxx" title="${esc(t("cancelLabel"))}"><span class="icon icon-close icon-13"></span></button>` : ""}
3478734889
</div>`;
3478834890
}).join("");
3478934891
const notifHtml = notifs.map(n => {
@@ -34834,17 +34936,19 @@ import { elgFanStep } from './printers/elegoo/widget_control.js';
3483434936
// "Remind me later" on the paxx notice — snooze without permanently
3483534937
// dismissing. stopPropagation so it doesn't also trigger the row's open.
3483634938
list.querySelectorAll(".notif-later").forEach(btn =>
34837-
btn.addEventListener("click", e => { e.stopPropagation(); _snoozePaxxNotif(); }));
34939+
btn.addEventListener("click", e => { e.stopPropagation();
34940+
if (btn.closest("[data-local-id]")?.dataset.localId === "ffgfw") _ffgFwSnooze(); else _snoozePaxxNotif(); }));
3483834941
// ✕ close on the paxx notice — the ONLY permanent block (this release never
3483934942
// notifies again). stopPropagation so it doesn't also trigger the row's open.
3484034943
list.querySelectorAll(".notif-dismiss-paxx").forEach(btn =>
34841-
btn.addEventListener("click", e => { e.stopPropagation(); _dismissPaxxNotif(); }));
34944+
btn.addEventListener("click", e => { e.stopPropagation(); if (btn.closest("[data-local-id]")?.dataset.localId === "ffgfw") _dismissFfgFwNotif(); else _dismissPaxxNotif(); }));
3484234945
list.querySelectorAll(".notif-item--clickable").forEach(row =>
3484334946
row.addEventListener("click", () => {
3484434947
closeNotifs();
3484534948
const a = row.dataset.localAction;
3484634949
if (a === "avatar") _changeMyAvatar();
3484734950
else if (a === "paxx") _openPaxxPrinter();
34951+
else if (a === "ffgfw") _openFfgFwPrinter();
3484834952
else if (a === "lowstock") _openLowStockProduct(row.dataset.localId);
3484934953
else if (COMMUNITY[a]) _openCommunityLink(a);
3485034954
}));

0 commit comments

Comments
 (0)