+
+
+ {web}
+
+
+
+ }
+ />
+ );
+}
diff --git a/apps/app/src/qr.tsx b/apps/app/src/qr.tsx
new file mode 100644
index 00000000..ba63c434
--- /dev/null
+++ b/apps/app/src/qr.tsx
@@ -0,0 +1,31 @@
+import { useMemo } from "react";
+import { encode } from "uqr";
+
+/**
+ * Dark modules on a white quiet zone in both themes: phone cameras often fail to read an inverted
+ * code.
+ */
+export function Qr(
+ { value, label, className }: { value: string; label: string; className?: string },
+) {
+ const { size, path } = useMemo(() => {
+ const { size, data } = encode(value, { border: 2 });
+ const path = data.flatMap((row, y) =>
+ row.flatMap((dark, x) => dark ? [`M${x} ${y}h1v1h-1z`] : [])
+ );
+ return { size, path: path.join("") };
+ }, [value]);
+
+ return (
+
+ );
+}
diff --git a/apps/host/src/diagnostics.ts b/apps/host/src/diagnostics.ts
index c29087e0..195ec409 100644
--- a/apps/host/src/diagnostics.ts
+++ b/apps/host/src/diagnostics.ts
@@ -7,6 +7,7 @@ import { shell as environment } from "./environment";
import { latest } from "./keys";
import type { Diagnostics } from "./protocol";
import * as tailnet from "./tailnet";
+import * as web from "./web";
async function git(): Promise {
const path = Bun.which("git", { PATH: process.env.PATH }) || undefined;
@@ -54,5 +55,6 @@ export async function diagnostics(sync?: boolean): Promise {
tailscale: config.loopback ? { ...tailscale, loopback: true } : tailscale,
directory: { ...directory.status(), secret: await secret },
shell: { ...environment },
+ web: web.address,
};
}
diff --git a/apps/host/src/gateway.ts b/apps/host/src/gateway.ts
index eeda124a..9ef852b6 100644
--- a/apps/host/src/gateway.ts
+++ b/apps/host/src/gateway.ts
@@ -1126,7 +1126,8 @@ export async function serve(port: number, loopback = false, script?: string): Pr
if (tls && !closing) {
const listener = { port: config.webPort ?? port + 1000, secure: true };
servers.push(team(machine, listener, tls));
- console.log(`and https://${machine.dns}:${listener.port}`);
+ web.served(machine, listener);
+ console.log(`and ${web.address}`);
}
} else if (!loopback) {
console.log("Tailscale is not running; channels stay on this machine");
diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts
index c19dab6b..42726ed8 100644
--- a/apps/host/src/protocol.ts
+++ b/apps/host/src/protocol.ts
@@ -60,6 +60,8 @@ export type Diagnostics = {
};
/** Whether the helper loaded the login shell's environment; `pending` while it retries. */
shell: { state: "adopted" | "pending" | "failed"; error?: string };
+ /** This host's HTTPS address on the tailnet; unset without a tailnet certificate. */
+ web?: string;
};
/** `offline`: the channel's host is unreachable. Hosted channels are never offline. */
diff --git a/apps/host/src/web.ts b/apps/host/src/web.ts
index f7c6925b..b0fe6bd5 100644
--- a/apps/host/src/web.ts
+++ b/apps/host/src/web.ts
@@ -44,6 +44,13 @@ export async function tls(machine: Machine): Promise<{ cert: string; key: string
export type Listener = { port: number; secure: boolean };
+/** The HTTPS address the owner's phone opens, once that listener is up. */
+export let address: string | undefined;
+
+export function served(machine: Machine, listener: Listener): void {
+ address = `https://${machine.dns}:${listener.port}`;
+}
+
/** Names this host answers to on the tailnet; anything else is DNS rebinding. */
export function hosts(machine: Machine, listener: Listener): Set {
const names = listener.secure ? [machine.dns] : [machine.dns, machine.name, machine.address];
diff --git a/bun.lock b/bun.lock
index 9ba89d22..9eda6cd3 100644
--- a/bun.lock
+++ b/bun.lock
@@ -24,6 +24,7 @@
"pdfjs-dist": "catalog:ui",
"react": "catalog:ui",
"react-dom": "catalog:ui",
+ "uqr": "catalog:ui",
},
"devDependencies": {
"@tailwindcss/vite": "catalog:ui",
@@ -227,6 +228,7 @@
"tailwind-merge": "3.5.0",
"tailwindcss": "4.3.0",
"tw-animate-css": "1.4.0",
+ "uqr": "0.1.3",
"vite": "7.3.1",
},
},
@@ -1699,6 +1701,8 @@
"update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="],
+ "uqr": ["uqr@0.1.3", "", {}, "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA=="],
+
"use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
diff --git a/package.json b/package.json
index ae75c995..40b1ba3d 100644
--- a/package.json
+++ b/package.json
@@ -62,6 +62,7 @@
"tailwind-merge": "3.5.0",
"tailwindcss": "4.3.0",
"tw-animate-css": "1.4.0",
+ "uqr": "0.1.3",
"vite": "7.3.1",
"@vitejs/plugin-react": "5.1.4",
"@xterm/addon-fit": "0.11.0",