@graphty/visual-review 0.2.1 → 0.2.2

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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Grid thumbnails: a capture scaled down for a tile. Scaling one 2400 x 1800 capture takes about
3
+ * 90 ms of CPU, so it runs in a few child processes, never on the server's own thread, where a
4
+ * grid of 36 tiles held every other request for seconds. Processes, not worker threads: threads
5
+ * share the server's memory, which grew past 500 MB while scaling, and every git the server then
6
+ * started took 20 ms instead of 3 (the fork copies the parent's page tables). The same file is the
7
+ * child, started with the FLAG argument.
8
+ */
9
+
10
+ import { fork } from "node:child_process";
11
+ import { availableParallelism } from "node:os";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ import { PNG } from "pngjs";
15
+
16
+ // A grid tile is about 190 CSS pixels wide: 400 image pixels keep it sharp on a 2x screen, at
17
+ // about a thirtieth of a 2400 x 1800 capture's memory once decoded.
18
+ const THUMB_WIDTH = 400;
19
+
20
+ /**
21
+ * A PNG scaled down to `width` pixels across (never up), each output pixel the average of the
22
+ * source pixels under it. For display in the grid only: comparisons use the full images.
23
+ * @param {Buffer} bytes the PNG
24
+ * @param {number} width the widest the result may be
25
+ * @returns {Buffer} the smaller PNG
26
+ */
27
+ export function thumbnail(bytes, width = THUMB_WIDTH) {
28
+ const src = PNG.sync.read(bytes);
29
+ const w = Math.min(width, src.width);
30
+ const h = Math.max(1, Math.round((src.height * w) / src.width));
31
+ const out = new PNG({ width: w, height: h });
32
+ for (let y = 0; y < h; y++) {
33
+ const [y0, y1] = [
34
+ Math.floor((y * src.height) / h),
35
+ Math.max(Math.floor(((y + 1) * src.height) / h), 1 + Math.floor((y * src.height) / h)),
36
+ ];
37
+ for (let x = 0; x < w; x++) {
38
+ const [x0, x1] = [
39
+ Math.floor((x * src.width) / w),
40
+ Math.max(Math.floor(((x + 1) * src.width) / w), 1 + Math.floor((x * src.width) / w)),
41
+ ];
42
+ const sum = [0, 0, 0, 0];
43
+ for (let sy = y0; sy < y1; sy++) {
44
+ for (let sx = x0; sx < x1; sx++) {
45
+ const i = (sy * src.width + sx) * 4;
46
+ for (let c = 0; c < 4; c++) {
47
+ sum[c] += src.data[i + c];
48
+ }
49
+ }
50
+ }
51
+ const n = (y1 - y0) * (x1 - x0);
52
+ const o = (y * w + x) * 4;
53
+ for (let c = 0; c < 4; c++) {
54
+ out.data[o + c] = Math.round(sum[c] / n);
55
+ }
56
+ }
57
+ }
58
+ return PNG.sync.write(out);
59
+ }
60
+
61
+ const FLAG = "--visual-review-thumbnails";
62
+ if (process.argv[2] === FLAG) {
63
+ process.on("message", (/** @type {Uint8Array} */ bytes) => {
64
+ try {
65
+ process.send({ png: thumbnail(Buffer.from(bytes)) });
66
+ } catch (err) {
67
+ process.send({ error: err.message });
68
+ }
69
+ });
70
+ }
71
+
72
+ // Half the cores, at most four: the rest stay free for the server, git and the downloads.
73
+ const SIZE = Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2)));
74
+ const idle = [];
75
+ let made = 0;
76
+ /** @type {{ load: () => Promise<Buffer>, resolve: (png: Buffer) => void, reject: (err: Error) => void }[]} */
77
+ const queue = [];
78
+
79
+ function pump() {
80
+ while (queue.length > 0 && (idle.length > 0 || made < SIZE)) {
81
+ let worker = idle.pop();
82
+ if (!worker) {
83
+ // Buffers cross as buffers ("advanced"), not as JSON. Idle children never keep the
84
+ // server's process alive, and they end with it.
85
+ worker = fork(fileURLToPath(import.meta.url), [FLAG], { serialization: "advanced" });
86
+ worker.unref();
87
+ worker.channel?.unref();
88
+ made++;
89
+ // One that dies while idle is dropped; a send to it would throw.
90
+ const child = worker;
91
+ child.on("error", () => {});
92
+ child.on("exit", () => {
93
+ if (idle.includes(child)) {
94
+ idle.splice(idle.indexOf(child), 1);
95
+ made--;
96
+ }
97
+ });
98
+ }
99
+ run(worker, queue.shift());
100
+ }
101
+ }
102
+
103
+ async function run(w, task) {
104
+ let bytes;
105
+ try {
106
+ bytes = await task.load();
107
+ } catch (err) {
108
+ idle.push(w);
109
+ task.reject(err);
110
+ pump();
111
+ return;
112
+ }
113
+ // A child that dies (out of memory, say) fails its task and is replaced by the next one.
114
+ const died = (code) => {
115
+ w.removeAllListeners("message");
116
+ made--;
117
+ task.reject(new Error(`the thumbnail process stopped (${code})`));
118
+ pump();
119
+ };
120
+ w.once("exit", died);
121
+ w.once("message", ({ png, error }) => {
122
+ w.removeListener("exit", died);
123
+ w.channel?.unref();
124
+ idle.push(w);
125
+ if (error) {
126
+ task.reject(new Error(error));
127
+ } else {
128
+ task.resolve(Buffer.from(png));
129
+ }
130
+ pump();
131
+ });
132
+ // Busy, a child keeps the server's process alive until it answers.
133
+ w.channel?.ref();
134
+ w.send(bytes);
135
+ }
136
+
137
+ /**
138
+ * `thumbnail` in a child process. The image is read only when a child takes it, so a long queue
139
+ * of thumbnails made in advance holds no images in memory.
140
+ * @param {() => Promise<Buffer>} load reads the PNG; what it throws, this rejects with
141
+ * @param {boolean} [urgent] a tile on screen now: ahead of the thumbnails made in advance
142
+ * @returns {Promise<Buffer>} the smaller PNG
143
+ */
144
+ export function scaled(load, urgent = false) {
145
+ return new Promise((resolve, reject) => {
146
+ queue[urgent ? "unshift" : "push"]({ load, resolve, reject });
147
+ pump();
148
+ });
149
+ }
@@ -2,18 +2,32 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
6
  <title>Visual review</title>
7
7
  <link rel="stylesheet" href="review.css" />
8
8
  <script type="module" src="review.js"></script>
9
9
  </head>
10
10
  <body>
11
- <header>
12
- <button type="button" id="home" class="link">Visual review</button>
13
- <nav id="crumbs"></nav>
14
- <span id="status" role="status"></span>
15
- <button type="button" id="copy-link" title="Copy a link to this screen">Copy link</button>
11
+ <header id="top">
12
+ <h1><button type="button" id="home" class="link">Visual review</button></h1>
13
+ <label class="picker"
14
+ ><span>Target</span
15
+ ><select id="pick-target"></select
16
+ ></label>
17
+ <label class="picker"
18
+ ><span>Project</span
19
+ ><select id="pick-project"></select
20
+ ></label>
21
+ <span id="finish-slot"></span>
22
+ <button type="button" id="keys-button" aria-keyshortcuts="?">Keys <kbd aria-hidden="true">?</kbd></button>
23
+ <button type="button" id="copy-link">Copy link</button>
16
24
  </header>
25
+ <div id="bar" role="region" aria-label="Tools"></div>
26
+ <div id="status-row" role="region" aria-label="Messages">
27
+ <span id="status" role="status"></span><span id="elapsed" aria-hidden="true"></span
28
+ ><span id="alert" role="alert"></span
29
+ ><button type="button" id="status-more" class="link" hidden>More</button>
30
+ </div>
17
31
  <main id="app" tabindex="-1"><p>Loading...</p></main>
18
32
  </body>
19
33
  </html>
@@ -0,0 +1,94 @@
1
+ /*
2
+ * The review page's WebAuthn code: registering the owner's passkey, and approving a Finish with
3
+ * it (Face ID or Touch ID). The server builds every challenge and checks every answer; this file
4
+ * only hands bytes between it and the browser.
5
+ *
6
+ * Safari needs a user gesture for both calls, so no network request may sit between the press and
7
+ * the WebAuthn call: the challenge is always fetched before the button that uses it is shown, and
8
+ * each call below reaches `navigator.credentials` before its first `await`.
9
+ */
10
+
11
+ // Own helpers: Uint8Array.fromBase64 is missing from the Safari versions still in use.
12
+ const toB64u = (buffer) => {
13
+ let s = "";
14
+ for (const b of new Uint8Array(buffer)) {
15
+ s += String.fromCharCode(b);
16
+ }
17
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
18
+ };
19
+ const fromB64u = (s) => {
20
+ const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4));
21
+ return Uint8Array.from(bin, (c) => c.charCodeAt(0));
22
+ };
23
+ const credential = (id) => ({ type: "public-key", id: fromB64u(id) });
24
+
25
+ /**
26
+ * Fetches a registration challenge. Call it before showing the button that registers.
27
+ * @param {(path: string, body?: object) => Promise<any>} api the page's API call
28
+ * @returns {Promise<{ challenge: string, userId: string, rpId: string, known: string[] }>} what
29
+ * registerPasskey needs
30
+ */
31
+ export const prepareRegistration = (api) => api("/api/passkey-challenge", {});
32
+
33
+ /**
34
+ * Creates a passkey for this page's host and asks the server to register it, which opens a pull
35
+ * request adding it. Call it straight from the button's click.
36
+ * @param {(path: string, body?: object) => Promise<any>} api the page's API call
37
+ * @param {{ challenge: string, userId: string, rpId: string, known: string[] }} prepared from
38
+ * prepareRegistration
39
+ * @param {string | ((made: PublicKeyCredential) => string)} label what passkeys.json calls the key,
40
+ * or a function naming it from the credential made (a platform passkey or a security key)
41
+ * @returns {Promise<{ entry: object, branch: string, pullRequest: string, gated: boolean }>} the
42
+ * server's answer
43
+ */
44
+ export async function registerPasskey(api, prepared, label) {
45
+ const created = navigator.credentials.create({
46
+ publicKey: {
47
+ rp: { id: prepared.rpId, name: "Visual review" },
48
+ user: { id: fromB64u(prepared.userId), name: "visual-review", displayName: "Visual review owner" },
49
+ challenge: fromB64u(prepared.challenge),
50
+ pubKeyCredParams: [{ type: "public-key", alg: -7 }],
51
+ authenticatorSelection: { residentKey: "preferred", userVerification: "required" },
52
+ attestation: "none",
53
+ excludeCredentials: prepared.known.map(credential),
54
+ },
55
+ });
56
+ const made = await created;
57
+ const r = made.response;
58
+ return api("/api/register", {
59
+ challenge: prepared.challenge,
60
+ credentialId: toB64u(made.rawId),
61
+ publicKey: toB64u(r.getPublicKey()),
62
+ algorithm: r.getPublicKeyAlgorithm(),
63
+ authenticatorData: toB64u(r.getAuthenticatorData()),
64
+ clientDataJSON: toB64u(r.clientDataJSON),
65
+ label: typeof label === "function" ? label(made) : label,
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Signs a prepared Finish's record with the passkey. Call it straight from the press that answers
71
+ * yes. A cancelled or refused prompt rejects with a NotAllowedError.
72
+ * @param {{ challenge: string, rpId: string, allowCredentials: string[] }} prepared from
73
+ * POST /api/finish-prepare
74
+ * @returns {Promise<{ credentialId: string, authenticatorData: string, clientDataJSON: string,
75
+ * signature: string }>} the approval POST /api/finish takes
76
+ */
77
+ export async function approve(prepared) {
78
+ const got = await navigator.credentials.get({
79
+ publicKey: {
80
+ challenge: fromB64u(prepared.challenge),
81
+ rpId: prepared.rpId,
82
+ allowCredentials: prepared.allowCredentials.map(credential),
83
+ userVerification: "required",
84
+ timeout: 120000,
85
+ },
86
+ });
87
+ const r = got.response;
88
+ return {
89
+ credentialId: toB64u(got.rawId),
90
+ authenticatorData: toB64u(r.authenticatorData),
91
+ clientDataJSON: toB64u(r.clientDataJSON),
92
+ signature: toB64u(r.signature),
93
+ };
94
+ }