Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function serve
created: 2026-10-15T03:39:45Z
norm: '1'
sig: 171797818ef4d836
body_hash: bbdb9eb136165a77
raw_hash: db94b1c01ca466d2
vouched: 2026-10-20T17:10:51Z
body_hash: 347ec5128df25eff
raw_hash: 4f8782ceb985ad81
vouched: 2026-10-21T23:57:08Z
lines: 668-751
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function serve
created: 2026-10-15T10:17:10Z
norm: '1'
sig: 171797818ef4d836
body_hash: bbdb9eb136165a77
raw_hash: db94b1c01ca466d2
vouched: 2026-10-20T17:10:51Z
body_hash: 347ec5128df25eff
raw_hash: 4f8782ceb985ad81
vouched: 2026-10-21T23:57:08Z
lines: 830-948
supersedes: 01M4Z2R1HEVDKEA8VEJ7XRJQ8F
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-20T17:04:56Z
norm: '1'
sig: 1acdc0f6a0895ac5
body_hash: f383b10cec21cd9b
raw_hash: 9875bf699ce7d092
vouched: 2026-10-20T17:10:51Z
body_hash: c1aca6bd21460ec9
raw_hash: 0c4c21e9c44f3962
vouched: 2026-10-21T23:57:08Z
lines: 1-1153
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5GEB6HV1WK8X6GAV7E9ZVGG
anchor: let address
created: 2026-10-21T23:57:02Z
norm: '1'
sig: 6745db67e4b91141
body_hash: e3b0c44298fc1c14
raw_hash: c379687473cfca71
lines: 48-48
---

set only after the HTTPS listener starts, so the phone row never offers an address that isn't served
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-14T12:13:47Z
norm: '1'
sig: 021fb596db81e6d0
body_hash: 7f91062de99c2f79
raw_hash: d907dc1c33b7e42f
vouched: 2026-10-19T20:50:34Z
body_hash: 09d9fabe6a70bd56
raw_hash: 072618b55fe50579
vouched: 2026-10-21T23:57:08Z
lines: 1-92
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-15T10:29:13Z
norm: '1'
sig: 021fb596db81e6d0
body_hash: 7f91062de99c2f79
raw_hash: d907dc1c33b7e42f
vouched: 2026-10-19T20:50:34Z
body_hash: 09d9fabe6a70bd56
raw_hash: 072618b55fe50579
vouched: 2026-10-21T23:57:08Z
lines: 1-94
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: '@file'
created: 2026-10-19T20:50:34Z
norm: '1'
sig: 021fb596db81e6d0
body_hash: 7f91062de99c2f79
raw_hash: d907dc1c33b7e42f
body_hash: 09d9fabe6a70bd56
raw_hash: 072618b55fe50579
vouched: 2026-10-21T23:57:08Z
lines: 1-95
---

Expand Down
3 changes: 2 additions & 1 deletion apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"electrobun": "catalog:desktop",
"pdfjs-dist": "catalog:ui",
"react": "catalog:ui",
"react-dom": "catalog:ui"
"react-dom": "catalog:ui",
"uqr": "catalog:ui"
},
"devDependencies": {
"@tailwindcss/vite": "catalog:ui",
Expand Down
44 changes: 44 additions & 0 deletions apps/app/src/host-settings.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";

import { Button } from "@ace/ui";
import { IconCheck, IconCopy } from "@ace/ui/icons";
import type { HelperAction, HelperState } from "@ace/desktop/protocol";
import type { Diagnostics } from "@ace/host/protocol";

import { CommandLine } from "./command-settings";
import { desktop } from "./desktop";
import { host } from "./host";
import { Qr } from "./qr";
import { Confirm, Group, Page, Problem, Row, Status, Value } from "./settings-layout";

/** The host's tool and team checks, re-read while `active` and whenever `refresh` is called. */
Expand Down Expand Up @@ -190,6 +192,11 @@ export function HostStatus({ active }: { active: boolean }) {
<CommandLine active={active} />
</Group>
)}
{checked?.tailscale.state === "running" && !checked.tailscale.loopback && (
<Group title="Phone">
<Phone web={checked.web} />
</Group>
)}
<Group
title="Tools"
action={
Expand Down Expand Up @@ -242,3 +249,40 @@ export function HostStatus({ active }: { active: boolean }) {
</Page>
);
}

/** This host's HTTPS address, which the owner's phone opens over the tailnet. */
function Phone({ web }: { web?: string }) {
const [copied, setCopied] = useState(false);
if (!web) {
return (
<Row
label="Open on your phone"
description="Turn on HTTPS in the Tailscale admin console's DNS settings, then restart Ace Helper."
/>
);
}
return (
<Row
label="Open on your phone"
description="Scan with a phone on your tailnet."
footer={
<div className="flex flex-wrap items-center gap-4">
<Qr value={web} label={`QR code for ${web}`} className="size-36 rounded-md" />
<div className="flex min-w-0 items-center gap-1">
<code className="truncate text-xs text-muted-foreground">{web}</code>
<Button
variant="ghost"
size="icon-sm"
aria-label="Copy address"
onClick={() => {
void navigator.clipboard.writeText(web).then(() => setCopied(true));
}}
>
{copied ? <IconCheck /> : <IconCopy />}
</Button>
</div>
</div>
}
/>
);
}
31 changes: 31 additions & 0 deletions apps/app/src/qr.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
role="img"
aria-label={label}
viewBox={`0 0 ${size} ${size}`}
shapeRendering="crispEdges"
className={className}
>
<rect width={size} height={size} fill="#fff" />
<path d={path} fill="#000" />
</svg>
);
}
2 changes: 2 additions & 0 deletions apps/host/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Diagnostics["tools"][number]> {
const path = Bun.which("git", { PATH: process.env.PATH }) || undefined;
Expand Down Expand Up @@ -54,5 +55,6 @@ export async function diagnostics(sync?: boolean): Promise<Diagnostics> {
tailscale: config.loopback ? { ...tailscale, loopback: true } : tailscale,
directory: { ...directory.status(), secret: await secret },
shell: { ...environment },
web: web.address,
};
}
3 changes: 2 additions & 1 deletion apps/host/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions apps/host/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
7 changes: 7 additions & 0 deletions apps/host/src/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const names = listener.secure ? [machine.dns] : [machine.dns, machine.name, machine.address];
Expand Down
4 changes: 4 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down