@paydlux/agent 0.1.10 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.d.ts +8 -0
- package/dist/agent.js +1 -1
- package/dist/cli.d.ts +20 -0
- package/dist/cli.js +117 -8
- package/dist/desk.d.ts +34 -0
- package/dist/desk.js +97 -12
- package/dist/gateway.d.ts +10 -2
- package/dist/gateway.js +20 -1
- package/dist/policy.d.ts +9 -0
- package/dist/policy.js +2 -2
- package/package.json +6 -2
- package/phone-app/app.js +264 -14
- package/phone-app/index.html +134 -55
- package/phone-app/manifest.webmanifest +5 -1
package/phone-app/app.js
CHANGED
|
@@ -23,6 +23,10 @@ const show = (id, on = true) => $(id).classList.toggle("hidden", !on);
|
|
|
23
23
|
const set = (id, v) => { $(id).textContent = v == null || v === "" ? "\u2014" : String(v); };
|
|
24
24
|
const err = (id, msg) => { $(id).textContent = msg || ""; };
|
|
25
25
|
|
|
26
|
+
// A short buzz on the two moments that matter. iOS ignores vibrate(); Android
|
|
27
|
+
// and desktop honour it. Guarded so it is never an uncaught error.
|
|
28
|
+
const buzz = (ms) => { try { navigator.vibrate?.(ms); } catch { /* no haptics */ } };
|
|
29
|
+
|
|
26
30
|
// ------------------------------------------------------------------ storage
|
|
27
31
|
// The approver key goes in IndexedDB, not localStorage. localStorage can only
|
|
28
32
|
// hold strings, which would force us to re-encode the key as something
|
|
@@ -91,11 +95,47 @@ async function api(path, opts = {}) {
|
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
// ---------------------------------------------------------------- key state
|
|
94
|
-
let approverKey = null; // the non-extractable CryptoKey
|
|
98
|
+
let approverKey = null; // the non-extractable CryptoKey (browser path)
|
|
95
99
|
let approverPublic = null; // its G... address, for display
|
|
96
100
|
let approverPubBytes = null; // its 32 raw bytes, for addSignature
|
|
97
101
|
|
|
102
|
+
// The native app (ios/) exposes a Keychain key behind Face ID through a
|
|
103
|
+
// WKWebView script bridge. When it is present it wins: the key is in the
|
|
104
|
+
// iPhone's hardware-protected Keychain, marked ThisDeviceOnly, and every sign
|
|
105
|
+
// is gated by a biometric. The WebCrypto path below stays for the browser, and
|
|
106
|
+
// the two are never both used — a payment is signed by exactly one key.
|
|
107
|
+
//
|
|
108
|
+
// WebKit gives a page registered against WKScriptMessageHandlerWithReply a
|
|
109
|
+
// promise-returning postMessage, so the round trip is a plain await. No
|
|
110
|
+
// callback registry, no global to collide with.
|
|
111
|
+
const native = {
|
|
112
|
+
available: () => !!(globalThis.webkit?.messageHandlers?.sign
|
|
113
|
+
&& globalThis.webkit?.messageHandlers?.publicKey),
|
|
114
|
+
publicKey: async () => {
|
|
115
|
+
const out = await globalThis.webkit.messageHandlers.publicKey.postMessage({});
|
|
116
|
+
return typeof out === "string" && out ? out : null;
|
|
117
|
+
},
|
|
118
|
+
sign: async (preimageHex) => {
|
|
119
|
+
const out = await globalThis.webkit.messageHandlers.sign.postMessage({ preimage: preimageHex });
|
|
120
|
+
if (out && out.signature) return out.signature;
|
|
121
|
+
throw new Error((out && out.error) || "the native signer refused");
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
98
125
|
async function loadKey() {
|
|
126
|
+
if (native.available()) {
|
|
127
|
+
// The native key needs no import: it is already on the device, behind Face
|
|
128
|
+
// ID. The public half is read from the bridge so the review screen can show
|
|
129
|
+
// which key will sign, and addSignature can attach the result.
|
|
130
|
+
try {
|
|
131
|
+
const hex = await native.publicKey();
|
|
132
|
+
if (hex) {
|
|
133
|
+
approverPubBytes = X.hexToBytes(hex);
|
|
134
|
+
approverPublic = X.encodeAccountEd25519(approverPubBytes);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
} catch { /* fall through to the browser key */ }
|
|
138
|
+
}
|
|
99
139
|
try { approverKey = await idbGet(); } catch { approverKey = null; }
|
|
100
140
|
if (!approverKey) return false;
|
|
101
141
|
// A non-extractable key cannot be asked for its own public bytes, so they were
|
|
@@ -107,6 +147,23 @@ async function loadKey() {
|
|
|
107
147
|
return true;
|
|
108
148
|
}
|
|
109
149
|
|
|
150
|
+
// The one place a signature is produced. Both paths end here, so the audit
|
|
151
|
+
// question "what can sign?" has one answer: this function, over a hash this file
|
|
152
|
+
// computed, after the human held the button.
|
|
153
|
+
async function signPreimage(preimageHex) {
|
|
154
|
+
// One return type for both paths: 64 raw bytes. The bridge answers in hex
|
|
155
|
+
// because a script message cannot carry a byte array, and a half-converted
|
|
156
|
+
// signature is the kind of bug that shows up as an invalid transaction rather
|
|
157
|
+
// than an error.
|
|
158
|
+
if (native.available()) {
|
|
159
|
+
const hex = await native.sign(preimageHex);
|
|
160
|
+
return X.hexToBytes(hex);
|
|
161
|
+
}
|
|
162
|
+
if (!approverKey) throw new Error("no approver key on this device");
|
|
163
|
+
return new Uint8Array(
|
|
164
|
+
await crypto.subtle.sign("Ed25519", approverKey, X.hexToBytes(preimageHex)));
|
|
165
|
+
}
|
|
166
|
+
|
|
110
167
|
async function enrol(seed) {
|
|
111
168
|
const trimmed = seed.trim();
|
|
112
169
|
// Import once as non-extractable (what we keep) and once as extractable
|
|
@@ -125,9 +182,15 @@ async function enrol(seed) {
|
|
|
125
182
|
|
|
126
183
|
// ------------------------------------------------------------------ render
|
|
127
184
|
function paintKeyState() {
|
|
128
|
-
|
|
129
|
-
show("
|
|
130
|
-
|
|
185
|
+
const have = !!approverKey || native.available();
|
|
186
|
+
show("enrol", !have);
|
|
187
|
+
show("ready", have);
|
|
188
|
+
if (have) {
|
|
189
|
+
set("who", approverPublic || "held by this app");
|
|
190
|
+
// In the native app the seed lives in the Keychain and the page has no way
|
|
191
|
+
// to write it, so the paste box would be a lie.
|
|
192
|
+
if (native.available()) show("seedRow", false);
|
|
193
|
+
}
|
|
131
194
|
}
|
|
132
195
|
|
|
133
196
|
function paintLinkState() {
|
|
@@ -154,10 +217,19 @@ const STATUS_TAG = {
|
|
|
154
217
|
expired: ["bad", "expired"],
|
|
155
218
|
};
|
|
156
219
|
|
|
220
|
+
/** Minutes left on a live request, or null. */
|
|
221
|
+
function minutesLeft(rec) {
|
|
222
|
+
if (!rec.expiresAt) return null;
|
|
223
|
+
if (rec.status !== "pending" && rec.status !== "awaiting_phone") return null;
|
|
224
|
+
return Math.round((Number(rec.expiresAt) * 1000 - Date.now()) / 60000);
|
|
225
|
+
}
|
|
226
|
+
|
|
157
227
|
/** A queue row. Built from DOM nodes so agent text can never be markup. */
|
|
158
228
|
function row(rec) {
|
|
159
229
|
const wrap = document.createElement("div");
|
|
160
|
-
|
|
230
|
+
const live = rec.status === "pending" || rec.status === "awaiting_phone";
|
|
231
|
+
wrap.className = "item" + (live ? " live" : "") +
|
|
232
|
+
(rec.status === "settled" || rec.status === "denied" ? " hist" : "");
|
|
161
233
|
|
|
162
234
|
const top = document.createElement("div");
|
|
163
235
|
top.className = "top";
|
|
@@ -185,8 +257,27 @@ function row(rec) {
|
|
|
185
257
|
when.textContent = ago(rec.at) + (rec.tx ? " \u00b7 " + rec.tx.slice(0, 12) + "\u2026" : "");
|
|
186
258
|
foot.append(when);
|
|
187
259
|
|
|
188
|
-
|
|
189
|
-
|
|
260
|
+
// The other way a payment dies is the clock. Show the time left on the row,
|
|
261
|
+
// so "I approved it and nothing happened" is not the first warning.
|
|
262
|
+
const left = minutesLeft(rec);
|
|
263
|
+
if (left !== null) {
|
|
264
|
+
const clock = document.createElement("span");
|
|
265
|
+
clock.className = "clock" + (left <= 5 ? " low" : "");
|
|
266
|
+
clock.textContent = left > 0 ? left + " min left" : "expired";
|
|
267
|
+
foot.append(clock);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// A failed row with no reason is a dead end: the human cannot tell a passed
|
|
271
|
+
// deadline from a wrong key, and both look like "it says failed".
|
|
272
|
+
if (rec.status === "failed" && rec.reason) {
|
|
273
|
+
const why = document.createElement("div");
|
|
274
|
+
why.className = "wants";
|
|
275
|
+
why.textContent = rec.reason;
|
|
276
|
+
wrap.append(top, uri, wants, why, foot);
|
|
277
|
+
return wrap;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (live) {
|
|
190
281
|
const btns = document.createElement("div");
|
|
191
282
|
btns.className = "btns";
|
|
192
283
|
const yes = document.createElement("button");
|
|
@@ -204,7 +295,17 @@ function row(rec) {
|
|
|
204
295
|
return wrap;
|
|
205
296
|
}
|
|
206
297
|
|
|
298
|
+
// The home-screen icon badge: the count of what is waiting. On an installed PWA
|
|
299
|
+
// this is the only way a person learns money is waiting without opening the app.
|
|
300
|
+
async function badge(n) {
|
|
301
|
+
try {
|
|
302
|
+
if (n > 0 && navigator.setAppBadge) await navigator.setAppBadge(n);
|
|
303
|
+
else if (navigator.clearAppBadge) await navigator.clearAppBadge();
|
|
304
|
+
} catch { /* badging is not granted, or not supported */ }
|
|
305
|
+
}
|
|
306
|
+
|
|
207
307
|
let queueTimer = null;
|
|
308
|
+
let lastLive = 0;
|
|
208
309
|
async function refreshQueue() {
|
|
209
310
|
if (!key()) return;
|
|
210
311
|
try {
|
|
@@ -226,9 +327,23 @@ async function refreshQueue() {
|
|
|
226
327
|
// so a fresh device never meets "paste the transaction" before its payments.
|
|
227
328
|
show("paste", !live.length);
|
|
228
329
|
set("qwhen", new Date().toLocaleTimeString());
|
|
330
|
+
set("qtitle", live.length ? `${live.length} waiting for you` : "Waiting for you");
|
|
229
331
|
err("qerr", "");
|
|
332
|
+
show("offline", false);
|
|
333
|
+
// buzz once when a new request arrives while the app is open
|
|
334
|
+
if (live.length > lastLive) buzz(30);
|
|
335
|
+
lastLive = live.length;
|
|
336
|
+
badge(live.length);
|
|
337
|
+
// Hand the count to the native shell: it owns the icon badge and the local
|
|
338
|
+
// notification, which a web page cannot raise on iOS. It gets a number and
|
|
339
|
+
// nothing else — no amount, no payee — because a lock-screen payment prompt
|
|
340
|
+
// is a payment decision made on a screen nobody unlocked deliberately.
|
|
341
|
+
try { globalThis.webkit?.messageHandlers?.queue?.postMessage({ waiting: live.length }); } catch { /* not native */ }
|
|
230
342
|
} catch (e) {
|
|
343
|
+
// Distinguish "the desk is down" from "this link was never issued": the
|
|
344
|
+
// first is a network problem the seller fixes, the second is a key problem.
|
|
231
345
|
err("qerr", e.message + " — if this is new, open the seller's approval link again");
|
|
346
|
+
if (!navigator.onLine) show("offline", true);
|
|
232
347
|
}
|
|
233
348
|
}
|
|
234
349
|
|
|
@@ -248,6 +363,17 @@ async function openReview(id) {
|
|
|
248
363
|
if (rec.hash && hash !== rec.hash) {
|
|
249
364
|
throw new Error("the transaction does not hash to what the desk advertised \u2014 refusing");
|
|
250
365
|
}
|
|
366
|
+
// The vault only accepts the key it was built with. A seed from an earlier
|
|
367
|
+
// run produces a perfectly valid signature over a perfectly valid
|
|
368
|
+
// transaction, and the ledger's only reply is BAD_AUTH \u2014 which reads like a
|
|
369
|
+
// bug and is really the wrong key. Say it before signing instead.
|
|
370
|
+
if (rec.approver && approverPublic && rec.approver !== approverPublic) {
|
|
371
|
+
throw new Error(
|
|
372
|
+
"this vault expects a different approver key.\n" +
|
|
373
|
+
"vault wants: " + rec.approver.slice(0, 12) + "\u2026\n" +
|
|
374
|
+
"this device: " + approverPublic.slice(0, 12) + "\u2026\n" +
|
|
375
|
+
"The seller re-ran the desk, which made a new key. Get the new seed.");
|
|
376
|
+
}
|
|
251
377
|
current = {
|
|
252
378
|
id, xdr: rec.xdr, hash,
|
|
253
379
|
amount: X.formatAmount(op.amount),
|
|
@@ -255,12 +381,26 @@ async function openReview(id) {
|
|
|
255
381
|
issuer: op.asset.issuer || "",
|
|
256
382
|
payee: X.encodeAccountEd25519(op.destination.pub),
|
|
257
383
|
vault: X.encodeAccountEd25519(env.tx.source.pub),
|
|
258
|
-
memo
|
|
384
|
+
// Read the memo out of the envelope, not out of the server's summary:
|
|
385
|
+
// the envelope is what is being signed, so it is the only source that
|
|
386
|
+
// cannot disagree with the bytes. xdr.js already renders every memo type
|
|
387
|
+
// to a plain string, so this is display-safe by construction.
|
|
388
|
+
memo: env.tx.memo || "(none)",
|
|
259
389
|
deadline: rec.expiresAt || env.tx.cond?.timeBounds?.max || "",
|
|
260
390
|
network: rec.network,
|
|
261
391
|
wants: rec.wants || "",
|
|
262
392
|
};
|
|
263
|
-
|
|
393
|
+
// The amount is the one number a person must read. It gets the biggest type
|
|
394
|
+
// on the screen and the token sits next to it, so "4.50" is never read
|
|
395
|
+
// without its unit.
|
|
396
|
+
$("amount").replaceChildren();
|
|
397
|
+
const num = document.createElement("span");
|
|
398
|
+
num.textContent = current.amount;
|
|
399
|
+
const tk = document.createElement("span");
|
|
400
|
+
tk.className = "tk";
|
|
401
|
+
tk.textContent = " " + current.token;
|
|
402
|
+
$("amount").append(num, tk);
|
|
403
|
+
set("wantsBig", current.wants);
|
|
264
404
|
set("token", current.issuer ? `${current.token} (${current.issuer.slice(0, 12)}\u2026)` : current.token);
|
|
265
405
|
set("payee", current.payee);
|
|
266
406
|
set("vault", current.vault);
|
|
@@ -269,7 +409,14 @@ async function openReview(id) {
|
|
|
269
409
|
set("deadline", current.deadline ? new Date(Number(current.deadline) * 1000).toLocaleString() : "none");
|
|
270
410
|
set("network", current.network === "public" ? "Stellar mainnet" : current.network);
|
|
271
411
|
set("hash", current.hash);
|
|
412
|
+
// The network belongs in the header, not buried in the list: signing a
|
|
413
|
+
// testnet transaction and signing real money look identical otherwise, and
|
|
414
|
+
// the chip is the one place a person glances before holding the button.
|
|
415
|
+
set("netChip", current.network === "public" ? "mainnet" : current.network);
|
|
416
|
+
show("netChip", true);
|
|
417
|
+
$("netChip").style.color = current.network === "public" ? "var(--ok)" : "var(--warn)";
|
|
272
418
|
err("signErr", "");
|
|
419
|
+
resetHold();
|
|
273
420
|
show("review", true);
|
|
274
421
|
show("queue", false);
|
|
275
422
|
$("review").scrollIntoView({ behavior: "smooth", block: "start" });
|
|
@@ -286,18 +433,81 @@ function backToQueue() {
|
|
|
286
433
|
refreshQueue();
|
|
287
434
|
}
|
|
288
435
|
|
|
436
|
+
// ------------------------------------------------------ hold-to-approve
|
|
437
|
+
// Approving money is the expensive action; rejecting is the safe one. So Reject
|
|
438
|
+
// is one tap and Approve needs a deliberate hold. This is what stops a pocket
|
|
439
|
+
// tap, a mis-tap, and the person who taps Approve and only then starts reading.
|
|
440
|
+
const HOLD_MS = 1200;
|
|
441
|
+
let holdStart = 0, holdTimer = null;
|
|
442
|
+
|
|
443
|
+
function resetHold() {
|
|
444
|
+
$("holdFill").style.width = "0";
|
|
445
|
+
$("holdLbl").textContent = "Hold to approve & sign";
|
|
446
|
+
$("approve").classList.remove("armed");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function armHold() {
|
|
450
|
+
holdStart = Date.now();
|
|
451
|
+
$("holdLbl").textContent = "Keep holding\u2026";
|
|
452
|
+
holdTimer = setInterval(() => {
|
|
453
|
+
const p = Math.min(1, (Date.now() - holdStart) / HOLD_MS);
|
|
454
|
+
$("holdFill").style.width = (p * 100) + "%";
|
|
455
|
+
if (p >= 1) {
|
|
456
|
+
clearInterval(holdTimer); holdTimer = null;
|
|
457
|
+
$("approve").classList.add("armed");
|
|
458
|
+
$("holdLbl").textContent = "Signed";
|
|
459
|
+
buzz([20, 40, 20]);
|
|
460
|
+
signAndSubmit();
|
|
461
|
+
}
|
|
462
|
+
}, 40);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function cancelHold() {
|
|
466
|
+
if (holdTimer) { clearInterval(holdTimer); holdTimer = null; }
|
|
467
|
+
if (!holdStart) return;
|
|
468
|
+
holdStart = 0;
|
|
469
|
+
if (!$("approve").classList.contains("armed")) resetHold();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function wireHold() {
|
|
473
|
+
const b = $("approve");
|
|
474
|
+
b.addEventListener("pointerdown", (e) => { e.preventDefault(); if (!holdTimer) armHold(); });
|
|
475
|
+
b.addEventListener("pointerup", cancelHold);
|
|
476
|
+
b.addEventListener("pointercancel", cancelHold);
|
|
477
|
+
b.addEventListener("pointerleave", cancelHold);
|
|
478
|
+
// keyboard / VoiceOver: hold is a pointer gesture, so Enter needs a confirm
|
|
479
|
+
b.addEventListener("keydown", (e) => {
|
|
480
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
481
|
+
e.preventDefault();
|
|
482
|
+
if (!confirm("Approve and sign this payment?")) return;
|
|
483
|
+
$("holdFill").style.width = "100%";
|
|
484
|
+
signAndSubmit();
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
289
489
|
// ------------------------------------------------------------------ sign
|
|
290
490
|
async function signAndSubmit() {
|
|
291
491
|
if (!current) return;
|
|
292
|
-
if (!approverKey) { err("signErr", "no approver key on this device"); return; }
|
|
492
|
+
if (!approverKey && !native.available()) { err("signErr", "no approver key on this device"); return; }
|
|
293
493
|
const button = $("approve");
|
|
294
494
|
button.disabled = true;
|
|
295
495
|
try {
|
|
296
496
|
// Re-hash at the moment of signing. What is signed is what was on screen.
|
|
297
497
|
const { hash } = await X.transactionHash(current.xdr, X.NETWORK_ID[current.network] || current.network);
|
|
298
498
|
if (hash !== current.hash) throw new Error("the transaction changed under you \u2014 nothing was signed");
|
|
299
|
-
|
|
499
|
+
// One signer for both paths. The preimage is built here, from the hash this
|
|
500
|
+
// file computed, so the native side is asked to sign exactly what was on
|
|
501
|
+
// screen and is never handed a transaction to interpret.
|
|
502
|
+
const pre = X.signaturePreimage(hash);
|
|
503
|
+
const sig = new Uint8Array(await signPreimage(X.hex(pre)));
|
|
300
504
|
if (!approverPubBytes) throw new Error("this device lost the approver's public key — re-import it");
|
|
505
|
+
// Prove the signature came from the key this screen named, before a byte
|
|
506
|
+
// of it leaves the device. A bridge that signs with some other key is then
|
|
507
|
+
// useless: its output never reaches the ledger.
|
|
508
|
+
if (!await globalThis.PaydluxVerify.verifySignature(sig, hash, approverPubBytes)) {
|
|
509
|
+
throw new Error("the signature does not match this key — nothing was sent");
|
|
510
|
+
}
|
|
301
511
|
const signed = X.addSignature(current.xdr, sig, approverPubBytes);
|
|
302
512
|
const out = await api("/signed/" + encodeURIComponent(current.id), {
|
|
303
513
|
method: "POST",
|
|
@@ -312,8 +522,12 @@ async function signAndSubmit() {
|
|
|
312
522
|
if (out.reason) set("resultLabel", out.reason);
|
|
313
523
|
show("result", true);
|
|
314
524
|
show("review", false);
|
|
525
|
+
buzz(out.status === "settled" ? [20, 40, 20] : 200);
|
|
526
|
+
badge(0);
|
|
315
527
|
} catch (e) {
|
|
316
528
|
err("signErr", e.message);
|
|
529
|
+
resetHold();
|
|
530
|
+
buzz(200);
|
|
317
531
|
} finally {
|
|
318
532
|
button.disabled = false;
|
|
319
533
|
}
|
|
@@ -322,6 +536,7 @@ async function signAndSubmit() {
|
|
|
322
536
|
async function reject(id) {
|
|
323
537
|
try {
|
|
324
538
|
await api("/deny/" + encodeURIComponent(id), { method: "POST" });
|
|
539
|
+
buzz(60);
|
|
325
540
|
refreshQueue();
|
|
326
541
|
} catch (e) {
|
|
327
542
|
err("qerr", e.message);
|
|
@@ -359,7 +574,10 @@ async function readPasted() {
|
|
|
359
574
|
if (want.trim().toLowerCase() !== hash.slice(0, 12)) {
|
|
360
575
|
throw new Error("that is not the hash the seller quoted \u2014 nothing was signed");
|
|
361
576
|
}
|
|
362
|
-
const sig = new Uint8Array(await
|
|
577
|
+
const sig = new Uint8Array(await signPreimage(X.hex(X.signaturePreimage(hash))));
|
|
578
|
+
if (!await globalThis.PaydluxVerify.verifySignature(sig, hash, approverPubBytes)) {
|
|
579
|
+
throw new Error("the signature does not match this key — nothing was produced");
|
|
580
|
+
}
|
|
363
581
|
set("signed", X.addSignature(xdr, sig, approverPubBytes));
|
|
364
582
|
set("resultLabel", "Signed \u2014 send this back to the seller");
|
|
365
583
|
$("resultTag").className = "tag ok";
|
|
@@ -385,6 +603,23 @@ async function boot() {
|
|
|
385
603
|
paintKeyState();
|
|
386
604
|
paintLinkState();
|
|
387
605
|
|
|
606
|
+
// iOS gives an installed PWA no browser chrome, and Safari offers no install
|
|
607
|
+
// button, so the instruction has to live inside the app. Shown once per device.
|
|
608
|
+
const standalone = matchMedia("(display-mode: standalone)").matches
|
|
609
|
+
|| navigator.standalone === true;
|
|
610
|
+
show("install", !standalone && !localStorage.getItem("paydlux.installDismissed"));
|
|
611
|
+
$("install").addEventListener("click", () => {
|
|
612
|
+
localStorage.setItem("paydlux.installDismissed", "1");
|
|
613
|
+
show("install", false);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// The offline shell: the app launches from cache with its icon and code even
|
|
617
|
+
// with no network, then fails honestly when it tries to read the queue. It
|
|
618
|
+
// never caches payment data — see sw.js.
|
|
619
|
+
if ("serviceWorker" in navigator) {
|
|
620
|
+
navigator.serviceWorker.register("sw.js", { scope: "./" }).catch(() => { /* not fatal */ });
|
|
621
|
+
}
|
|
622
|
+
|
|
388
623
|
$("enrolBtn").addEventListener("click", async () => {
|
|
389
624
|
err("enrolErr", "");
|
|
390
625
|
try {
|
|
@@ -395,6 +630,7 @@ async function boot() {
|
|
|
395
630
|
err("enrolErr", e.message);
|
|
396
631
|
}
|
|
397
632
|
});
|
|
633
|
+
$("seed").addEventListener("keydown", (e) => { if (e.key === "Enter") $("enrolBtn").click(); });
|
|
398
634
|
$("forget").addEventListener("click", async () => {
|
|
399
635
|
await idbDel();
|
|
400
636
|
localStorage.removeItem("paydlux.pub");
|
|
@@ -411,7 +647,7 @@ async function boot() {
|
|
|
411
647
|
paintLinkState();
|
|
412
648
|
refreshQueue();
|
|
413
649
|
});
|
|
414
|
-
|
|
650
|
+
wireHold();
|
|
415
651
|
$("reject").addEventListener("click", () => current && reject(current.id) && backToQueue());
|
|
416
652
|
$("again").addEventListener("click", backToQueue);
|
|
417
653
|
$("read").addEventListener("click", readPasted);
|
|
@@ -421,9 +657,23 @@ async function boot() {
|
|
|
421
657
|
setTimeout(() => { $("copy").textContent = "Copy"; }, 1500);
|
|
422
658
|
});
|
|
423
659
|
|
|
660
|
+
// Poll only while visible. An approver app left open on a phone should not
|
|
661
|
+
// wake the radio every few seconds in a pocket.
|
|
662
|
+
const startPolling = () => {
|
|
663
|
+
if (queueTimer) return;
|
|
664
|
+
queueTimer = setInterval(refreshQueue, 4000);
|
|
665
|
+
refreshQueue();
|
|
666
|
+
};
|
|
667
|
+
const stopPolling = () => { if (queueTimer) { clearInterval(queueTimer); queueTimer = null; } };
|
|
668
|
+
document.addEventListener("visibilitychange", () => {
|
|
669
|
+
if (document.hidden) stopPolling(); else if (key()) startPolling();
|
|
670
|
+
});
|
|
671
|
+
window.addEventListener("online", () => { show("offline", false); refreshQueue(); });
|
|
672
|
+
window.addEventListener("offline", () => show("offline", true));
|
|
673
|
+
|
|
424
674
|
if (key()) {
|
|
425
675
|
await refreshQueue();
|
|
426
|
-
|
|
676
|
+
if (!document.hidden) startPolling();
|
|
427
677
|
const id = new URLSearchParams(localStorage.getItem(LINK)?.split("?")[1] || "").get("id");
|
|
428
678
|
if (id) openReview(id);
|
|
429
679
|
}
|