Public: anyone can read this; the crew can change it.
The terminal shows a forge's claim link as a QR beside the link, so a phone takes it from the screen #11759
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11460-the-terminal-shows-a-forge-s-claim-link → main 3 commits fork 2 threads · 2 unresolved
8 files
changed +144
−13
docs/features/forge-link-qr.md added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | # A forge's claim link as a QR in the terminal | |
| 2 | ||
| 3 | After you make a forge in the terminal (Start my forge, New forge, or Forge it), the claim link is shown once in a dialog. When the terminal has room, a QR code of exactly that link is drawn between the link and the help text, so a phone can take it from the screen with no typing. | |
| 4 | ||
| 5 | - The QR is dark on light whatever the theme, with a two-module quiet zone. | |
| 6 | - It needs the whole code to fit. A narrow or short terminal shows the link alone, as before. | |
| 7 | - The code is built from the link on screen, held in memory only, and never logged or written to disk. A resize redraws it; the link is not printed again. | |
| 8 | - Other links the terminal shows (opening a page on the web) have no QR. |
scripts/unreached-allow.json +1 −2
| @@ -1,6 +1,5 @@ | ||
| 1 | 1 | [ |
| 2 | 2 | "src/perf/page_budget.ts", |
| 3 | 3 | "src/perf/page_budget_read.ts", |
| 4 | "src/spawn/pod.ts", | |
| 5 | "src/tui/ui/qr.ts" | |
| 4 | "src/spawn/pod.ts" | |
| 6 | 5 | ] |
src/tui/screens/common.ts +36 −7
| @@ -25,11 +25,13 @@ import { G, Screen as Surface, type Rect } from "../term/screen.ts"; | ||
| 25 | 25 | import { style } from "../term/style.ts"; |
| 26 | 26 | import { pad, truncate, wrap } from "../term/width.ts"; |
| 27 | 27 | import { fill, len, split } from "../ui/layout.ts"; |
| 28 | import { drawQr, qrSize, terminalQr } from "../ui/qr.ts"; | |
| 28 | 29 | import type { Tokens } from "../ui/theme.ts"; |
| 29 | 30 | import { icon, type IconSet } from "../ui/icons.ts"; |
| 30 | 31 | import { |
| 31 | 32 | card, |
| 32 | 33 | dialog, |
| 34 | DIALOG_CHROME_ROWS, | |
| 33 | 35 | input, |
| 34 | 36 | row, |
| 35 | 37 | scrollbar, |
| @@ -851,8 +853,17 @@ export function openOnWeb( | ||
| 851 | 853 | * terminal's own drag-to-select work inside a mouse-aware app. `c` copies |
| 852 | 854 | * through OSC 52 as well; `enter` opens a browser when one is reachable. |
| 853 | 855 | */ |
| 854 | export function linkScreen(ctx: Ctx, url: string, parent?: Screen): Screen { | |
| 856 | export function linkScreen( | |
| 857 | ctx: Ctx, | |
| 858 | url: string, | |
| 859 | parent?: Screen, | |
| 860 | opts: { qr?: boolean } = {}, | |
| 861 | ): Screen { | |
| 855 | 862 | ctx.mouse(false); |
| 863 | // Encoded once, here, from exactly the link shown; held in this closure | |
| 864 | // only — never logged, never written anywhere. Render only draws it, so a | |
| 865 | // resize redraws the same cells and prints nothing twice. | |
| 866 | const code = opts.qr === true ? terminalQr(url) : undefined; | |
| 856 | 867 | return { |
| 857 | 868 | title: "Link", |
| 858 | 869 | keys: [ |
| @@ -867,16 +878,34 @@ export function linkScreen(ctx: Ctx, url: string, parent?: Screen): Screen { | ||
| 867 | 878 | }, |
| 868 | 879 | render(area, s) { |
| 869 | 880 | parent?.render(area, s); |
| 881 | const text = [ | |
| 882 | url, | |
| 883 | "", | |
| 884 | "Select the address to copy it: the mouse is yours here.", | |
| 885 | "c copies through the terminal too, when it allows it.", | |
| 886 | ]; | |
| 887 | // The QR goes between the link and the help, only when the whole code | |
| 888 | // fits inside the dialog; otherwise the link alone, as before. | |
| 889 | const size = code === undefined ? undefined : qrSize(code); | |
| 890 | const width = Math.max(54, size === undefined ? 0 : size.cols + 4, [...url].length + 4); | |
| 891 | const room = | |
| 892 | size !== undefined && | |
| 893 | width <= area.w - 4 && | |
| 894 | text.length + 1 + size.rows + DIALOG_CHROME_ROWS <= area.h - 2; | |
| 895 | // Two lines (the link, a blank) come first; the QR takes the blank | |
| 896 | // rows reserved for it, its origin given by the dialog's own body rect. | |
| 897 | const body = room | |
| 898 | ? [url, "", ...new Array<string>(size.rows).fill(""), "", ...text.slice(2)] | |
| 899 | : text; | |
| 870 | 900 | dialog(s, area, ctx.theme.c, { |
| 871 | 901 | title: "Link", |
| 872 | body: [ | |
| 873 | url, | |
| 874 | "", | |
| 875 | "Select the address to copy it: the mouse is yours here.", | |
| 876 | "c copies through the terminal too, when it allows it.", | |
| 877 | ], | |
| 902 | body, | |
| 878 | 903 | actions: [{ label: "Copy" }, { label: "Close" }], |
| 879 | 904 | selected: 0, |
| 905 | width, | |
| 906 | ...(room && code !== undefined | |
| 907 | ? { paint: (b: Rect) => void drawQr(s, { x: b.x, y: b.y + 2, w: b.w, h: size.rows }, code) } | |
| 908 | : {}), | |
| 880 | 909 | }); |
| 881 | 910 | }, |
| 882 | 911 | key(e) { |
src/tui/screens/forge_it.ts +1 −1
| @@ -42,7 +42,7 @@ export function forgeItScreen(f: Session, ctx: Ctx, repo: Repo, parent: Screen): | ||
| 42 | 42 | if (typeof link !== "string") |
| 43 | 43 | return ctx.toast(`${String(made.body["name"])} was forged before; its link is not shown again`, "attn"); |
| 44 | 44 | ctx.toast(`${String(made.body["name"])} is ready`, "success"); |
| 45 | ctx.replace(linkScreen(ctx, link)); | |
| 45 | ctx.replace(linkScreen(ctx, link, undefined, { qr: true })); | |
| 46 | 46 | }); |
| 47 | 47 | }, |
| 48 | 48 | }); |
src/tui/screens/newforge.ts +1 −1
| @@ -156,7 +156,7 @@ export function newForgeScreen(f: Session, ctx: Ctx, initial: Record<string, str | ||
| 156 | 156 | const name = String(body["name"] ?? val("name")); |
| 157 | 157 | if (typeof link === "string") { |
| 158 | 158 | ctx.toast(`${name} is ready`, "success"); |
| 159 | ctx.replace(linkScreen(ctx, link)); | |
| 159 | ctx.replace(linkScreen(ctx, link, undefined, { qr: true })); | |
| 160 | 160 | } else { |
| 161 | 161 | ctx.toast(`${name} already exists; its link is not shown again`, "attn"); |
| 162 | 162 | ctx.pop(); |
src/tui/screens/startforge.ts +1 −1
| @@ -43,7 +43,7 @@ export function startForgeScreen(f: Session, ctx: Ctx): Screen { | ||
| 43 | 43 | return; |
| 44 | 44 | } |
| 45 | 45 | ctx.toast(`${String(made.body["name"] ?? name)} is ready`, "success"); |
| 46 | ctx.replace(linkScreen(ctx, link)); | |
| 46 | ctx.replace(linkScreen(ctx, link, undefined, { qr: true })); | |
| 47 | 47 | } finally { |
| 48 | 48 | busy = false; |
| 49 | 49 | ctx.invalidate(); |
src/tui/ui/widgets.ts +7 −1
| @@ -714,6 +714,9 @@ export function menu( | ||
| 714 | 714 | * draws the parent first, then this paints over it, and the dialog screen's |
| 715 | 715 | * `key()` returns true for everything. |
| 716 | 716 | */ |
| 717 | /** Rows a dialog spends outside its body: border, padding and the button bar. */ | |
| 718 | export const DIALOG_CHROME_ROWS = 6; | |
| 719 | ||
| 717 | 720 | export function dialog( |
| 718 | 721 | s: Screen, |
| 719 | 722 | area: Rect, |
| @@ -724,6 +727,8 @@ export function dialog( | ||
| 724 | 727 | actions: readonly { label: string; variant?: ButtonVariant }[]; |
| 725 | 728 | selected?: number; |
| 726 | 729 | width?: number; |
| 730 | /** Called with the body's own rect, to draw something among its lines. */ | |
| 731 | paint?: (body: Rect) => void; | |
| 727 | 732 | }, |
| 728 | 733 | ): Rect[] { |
| 729 | 734 | // Dim rather than blank: the user keeps their place in the page behind. |
| @@ -736,7 +741,7 @@ export function dialog( | ||
| 736 | 741 | ); |
| 737 | 742 | } |
| 738 | 743 | const w = Math.min(o.width ?? 54, area.w - 4); |
| 739 | const h = Math.min(o.body.length + 6, area.h - 2); | |
| 744 | const h = Math.min(o.body.length + DIALOG_CHROME_ROWS, area.h - 2); | |
| 740 | 745 | const r = center(area, w, h); |
| 741 | 746 | const inner = card(s, r, c, { title: o.title, focused: true }); |
| 742 | 747 | o.body.forEach((line, i) => { |
| @@ -749,6 +754,7 @@ export function dialog( | ||
| 749 | 754 | inner.x + inner.w, |
| 750 | 755 | ); |
| 751 | 756 | }); |
| 757 | o.paint?.(inner); | |
| 752 | 758 | const barY = inner.y + inner.h - 1; |
| 753 | 759 | const widths = o.actions.map((a) => len(displayWidth(a.label) + 4)); |
| 754 | 760 | const slots = split({ x: inner.x, y: barY, w: inner.w, h: 1 }, "h", [ |
test/tui-forge-link-qr.test.ts added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | /** | |
| 2 | * The claim link as a QR beside the link: the matrix is the link's, it draws | |
| 3 | * only when the whole code fits, and a redraw never repeats the link. | |
| 4 | */ | |
| 5 | import { expect, test } from "bun:test"; | |
| 6 | import { createApp } from "../src/app.ts"; | |
| 7 | import { qrRows } from "../src/design/qr.ts"; | |
| 8 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 9 | import { linkScreen } from "../src/tui/screens/common.ts"; | |
| 10 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 11 | import { terminalQr } from "../src/tui/ui/qr.ts"; | |
| 12 | import { mkdtempSync } from "node:fs"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | import { settleApp } from "./helpers/tui.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | const LINK = "https://sunny-day--forge.test/claim#0123456789abcdef0123456789abcdef"; | |
| 20 | ||
| 21 | async function shown(cols: number, rows: number, qr: boolean, link = LINK) { | |
| 22 | const app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-qr-")), url: "https://forge.test", port: 0 }); | |
| 23 | const terminal = headlessTerminal(cols, rows); | |
| 24 | const { app: tui } = createForgeApp(app, { sshUsername: "git", remoteAddress: "203.0.113.9", fingerprint: "SHA256:t", user: undefined }, { terminal, exitHooks: false }); | |
| 25 | tui.start(); | |
| 26 | tui.ctx.push(linkScreen(tui.ctx, link, undefined, { qr })); | |
| 27 | await settleApp(tui); | |
| 28 | const text = tui.screen.toText(); | |
| 29 | tui.stop?.(); | |
| 30 | app.db.close(); | |
| 31 | return text; | |
| 32 | } | |
| 33 | ||
| 34 | /** The drawn QR read back off the screen: the rows under the link line. */ | |
| 35 | function drawn(text: string, link: string, rows: number): string[] { | |
| 36 | const lines = text.split("\n"); | |
| 37 | const at = lines.findIndex((l) => l.includes(link)); | |
| 38 | expect(at).toBeGreaterThanOrEqual(0); | |
outdated
Tautological: encoder compared with the same encoder. It cannot fail if the matrix is wrong. Decode the grid independently, or assert that different links draw different rows and that the drawn rows match the passed link. |
||
| 39 | return lines.slice(at + 2, at + 2 + rows); | |
| 40 | } | |
| 41 | ||
| 42 | test("the QR is drawn exactly in the rows reserved under the link, and is the link's", async () => { | |
| 43 | const code = terminalQr(LINK)!; | |
| 44 | const want = qrRows(code); | |
| 45 | const text = await shown(120, 50, true); | |
| 46 | const got = drawn(text, LINK, want.length); | |
| 47 | // Whole rows, quiet zone included, all at one column: aligned, not merely present. | |
| 48 | const xs = want.flatMap((row, i) => (row.trim() === "" ? [] : [got[i]!.indexOf(row)])); | |
| 49 | expect(xs.every((x) => x > 0 && x === xs[0])).toBe(true); | |
| 50 | // The rows above (the blank after the link) and below it hold no modules. | |
| 51 | const lines = text.split("\n"); | |
| 52 | const at = lines.findIndex((l) => l.includes(LINK)); | |
| 53 | expect(lines[at + 1]!.replace(/[│\s]/g, "")).toBe(""); | |
| 54 | expect(lines[at + 2 + want.length]!.replace(/[│\s]/g, "")).toBe(""); | |
| 55 | expect(text.split(LINK).length - 1).toBe(1); | |
| 56 | }); | |
| 57 | ||
| 58 | test("the drawn rows follow the link passed: another link draws other rows", async () => { | |
| 59 | const other = "https://other-forge--forge.test/claim#fedcba9876543210fedcba9876543210"; | |
| 60 | const a = qrRows(terminalQr(LINK)!); | |
| 61 | const b = qrRows(terminalQr(other)!); | |
| 62 | expect(a).not.toEqual(b); | |
| 63 | const text = await shown(120, 50, true, other); | |
| 64 | const got = drawn(text, other, b.length); | |
| 65 | b.forEach((row, i) => expect(got[i]!.includes(row)).toBe(true)); | |
| 66 | expect(got.every((l, i) => l.includes(a[i]!))).toBe(false); | |
| 67 | }); | |
| 68 | ||
| 69 | test("a link longer than the default dialog is shown whole, with and without the QR", async () => { | |
| 70 | const long = `https://a-rather-long-forge-name--forge.test/claim#${"0123456789abcdef".repeat(4)}`; | |
| 71 | expect([...long].length).toBeGreaterThan(54); | |
| 72 | for (const qr of [true, false]) expect(await shown(160, 50, qr, long)).toContain(long); | |
| 73 | }); | |
| 74 | ||
| 75 | test("a narrow or short terminal shows the link and no QR", async () => { | |
| 76 | const code = terminalQr(LINK)!; | |
| 77 | const rows = qrRows(code).filter((r) => r.trim() !== ""); | |
| 78 | for (const [c, r] of [[40, 50], [120, 12]] as const) { | |
| 79 | const text = await shown(c, r, true); | |
| 80 | expect(text).toContain("Link"); | |
| 81 | for (const row of rows) expect(text).not.toContain(row.trimEnd()); | |
| 82 | } | |
| 83 | }); | |
| 84 | ||
| 85 | test("without the option there is no QR", async () => { | |
| 86 | const code = terminalQr(LINK)!; | |
| 87 | const text = await shown(120, 50, false); | |
| 88 | expect(text).not.toContain(qrRows(code).filter((r) => r.trim() !== "")[0]!.trimEnd()); | |
| 89 | }); |
new above
offline
The +6 / +2 / +1 offsets are tied to dialog's internal padding by hand. If dialog changes, the QR draws over the help text. Take the body origin from the dialog (return its rect) or pin it with a test on the exact row of the first QR line.