codehost 0.1.0 → 0.3.1

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,222 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { readdir } from "node:fs/promises";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ // Resolves a runnable VS Code CLI binary for `code serve-web`. Strategy
8
+ // ("managed, prefer system"): honor an explicit override, then a system `code`
9
+ // on PATH, otherwise download + cache Microsoft's standalone ~31MB "VS Code
10
+ // CLI" binary for this platform. The standalone CLI fully supports serve-web
11
+ // with the same flags as a full install. Update cadence is "check but cache":
12
+ // the managed binary's version is re-checked against the stable channel at most
13
+ // once per ~24h; otherwise the cached binary is used with no network call.
14
+
15
+ const CACHE_ROOT = join(homedir(), ".codehost", "vscode");
16
+ const STATE_FILE = join(CACHE_ROOT, "state.json");
17
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
18
+ const UPDATE_API = "https://update.code.visualstudio.com/api/update";
19
+
20
+ interface Manifest {
21
+ url: string;
22
+ version: string;
23
+ productVersion: string;
24
+ sha256hash: string;
25
+ }
26
+
27
+ interface State {
28
+ /** Commit-style version id from the manifest. */
29
+ version: string;
30
+ /** Human version, e.g. "1.123.0". */
31
+ productVersion: string;
32
+ /** Absolute path to the extracted `code`/`code.exe`. */
33
+ binPath: string;
34
+ /** Wall-clock ms of the last manifest check. */
35
+ checkedAt: number;
36
+ }
37
+
38
+ /**
39
+ * Return a path (or bare `"code"`) that can be spawned to run `serve-web`.
40
+ * Downloads and caches the standalone CLI on first use when no system VS Code
41
+ * is available. `opts.force` skips the 24h throttle (used by `codehost update`).
42
+ */
43
+ export async function resolveCodeBinary(opts: { force?: boolean } = {}): Promise<string> {
44
+ const override = process.env.CODEHOST_CODE_BIN;
45
+ if (override) {
46
+ if (!existsSync(override)) {
47
+ throw new Error(`CODEHOST_CODE_BIN points to a missing file: ${override}`);
48
+ }
49
+ return override;
50
+ }
51
+
52
+ // Prefer a user-owned `code` on PATH — but only if it actually runs. A broken
53
+ // or stub `code` should fall through to a managed install/upgrade.
54
+ const system = Bun.which("code");
55
+ if (system && runsOk(system)) return system;
56
+
57
+ return ensureManagedBinary(opts.force ?? false);
58
+ }
59
+
60
+ async function ensureManagedBinary(force: boolean): Promise<string> {
61
+ const key = platformKey();
62
+ const state = readState();
63
+
64
+ const fresh = state && existsSync(state.binPath) && Date.now() - state.checkedAt < CHECK_INTERVAL_MS;
65
+ if (fresh && !force) return state!.binPath;
66
+
67
+ let manifest: Manifest;
68
+ try {
69
+ manifest = await fetchManifest(key);
70
+ } catch (err) {
71
+ // Offline: fall back to whatever we have cached, otherwise fail loudly.
72
+ if (state && existsSync(state.binPath)) {
73
+ console.warn(`[codehost] could not check for VS Code updates (${(err as Error).message}); using cached ${state.productVersion}`);
74
+ return state.binPath;
75
+ }
76
+ throw new Error(
77
+ `Unable to download VS Code and none is cached: ${(err as Error).message}. ` +
78
+ `Install VS Code's \`code\` CLI manually, or set CODEHOST_CODE_BIN to an existing binary.`,
79
+ );
80
+ }
81
+
82
+ // Already on the latest stable: just refresh the throttle timestamp.
83
+ if (state && state.version === manifest.version && existsSync(state.binPath)) {
84
+ writeState({ ...state, checkedAt: Date.now() });
85
+ return state.binPath;
86
+ }
87
+
88
+ console.log(`[codehost] installing VS Code ${manifest.productVersion} (${key})…`);
89
+ const binPath = await downloadAndExtract(manifest, key);
90
+ writeState({
91
+ version: manifest.version,
92
+ productVersion: manifest.productVersion,
93
+ binPath,
94
+ checkedAt: Date.now(),
95
+ });
96
+ console.log(`[codehost] VS Code ${manifest.productVersion} ready at ${binPath}`);
97
+ return binPath;
98
+ }
99
+
100
+ /** Map this host to a `cli-<os>-<arch>` key understood by the update API. */
101
+ function platformKey(): string {
102
+ const arch = process.arch; // "x64" | "arm64" | "arm" | ...
103
+ if (process.platform === "darwin") {
104
+ return arch === "arm64" ? "cli-darwin-arm64" : "cli-darwin-x64";
105
+ }
106
+ if (process.platform === "win32") {
107
+ return arch === "arm64" ? "cli-win32-arm64" : "cli-win32-x64";
108
+ }
109
+ // Linux: distinguish musl (Alpine) from glibc, and map arch.
110
+ const os = isMusl() ? "alpine" : "linux";
111
+ if (arch === "arm64") return `cli-${os}-arm64`;
112
+ if (arch === "arm") return `cli-${os}-armhf`;
113
+ return `cli-${os}-x64`;
114
+ }
115
+
116
+ /** True if `<bin> --version` exits cleanly — i.e. the binary actually works. */
117
+ function runsOk(bin: string): boolean {
118
+ const r = spawnSync(bin, ["--version"], { stdio: "ignore", timeout: 10_000 });
119
+ return r.status === 0;
120
+ }
121
+
122
+ function isMusl(): boolean {
123
+ if (existsSync("/etc/alpine-release")) return true;
124
+ // `ldd --version` mentions "musl" on musl systems; ignore failures.
125
+ const r = spawnSync("ldd", ["--version"], { encoding: "utf8" });
126
+ return /musl/i.test(`${r.stdout ?? ""}${r.stderr ?? ""}`);
127
+ }
128
+
129
+ async function fetchManifest(key: string): Promise<Manifest> {
130
+ const res = await fetch(`${UPDATE_API}/${key}/stable/latest`);
131
+ if (!res.ok) throw new Error(`manifest HTTP ${res.status} for ${key}`);
132
+ const m = (await res.json()) as Partial<Manifest>;
133
+ if (!m.url || !m.version || !m.sha256hash) {
134
+ throw new Error(`malformed manifest for ${key}`);
135
+ }
136
+ return {
137
+ url: m.url,
138
+ version: m.version,
139
+ productVersion: m.productVersion ?? m.version,
140
+ sha256hash: m.sha256hash,
141
+ };
142
+ }
143
+
144
+ async function downloadAndExtract(manifest: Manifest, key: string): Promise<string> {
145
+ mkdirSync(CACHE_ROOT, { recursive: true });
146
+ const isZip = manifest.url.endsWith(".zip");
147
+ const tmpArchive = join(tmpdir(), `codehost-vscode-${manifest.version}${isZip ? ".zip" : ".tar.gz"}`);
148
+
149
+ const res = await fetch(manifest.url);
150
+ if (!res.ok) throw new Error(`download HTTP ${res.status}`);
151
+ await Bun.write(tmpArchive, res);
152
+
153
+ await verifySha256(tmpArchive, manifest.sha256hash);
154
+
155
+ // Extract into a fresh temp dir, then move the single binary into the cache.
156
+ const extractDir = join(tmpdir(), `codehost-vscode-x-${manifest.version}`);
157
+ rmSync(extractDir, { recursive: true, force: true });
158
+ mkdirSync(extractDir, { recursive: true });
159
+ // bsdtar (macOS / Windows 10+) extracts .zip via `tar -xf`; GNU tar handles
160
+ // the Linux .tar.gz with `-xzf`.
161
+ const tarArgs = isZip ? ["-xf", tmpArchive] : ["-xzf", tmpArchive];
162
+ const tar = spawnSync("tar", [...tarArgs, "-C", extractDir], { encoding: "utf8" });
163
+ if (tar.status !== 0) {
164
+ throw new Error(`failed to extract VS Code archive: ${tar.stderr ?? tar.error?.message ?? "tar error"}`);
165
+ }
166
+
167
+ const exe = process.platform === "win32" ? "code.exe" : "code";
168
+ const found = await findFile(extractDir, exe);
169
+ if (!found) throw new Error(`extracted archive did not contain ${exe}`);
170
+
171
+ const destDir = join(CACHE_ROOT, manifest.version);
172
+ rmSync(destDir, { recursive: true, force: true });
173
+ mkdirSync(destDir, { recursive: true });
174
+ const destBin = join(destDir, exe);
175
+ renameSync(found, destBin);
176
+ if (process.platform !== "win32") {
177
+ const { chmodSync } = await import("node:fs");
178
+ chmodSync(destBin, 0o755);
179
+ }
180
+
181
+ rmSync(tmpArchive, { force: true });
182
+ rmSync(extractDir, { recursive: true, force: true });
183
+ return destBin;
184
+ }
185
+
186
+ async function verifySha256(path: string, expected: string): Promise<void> {
187
+ const hasher = new Bun.CryptoHasher("sha256");
188
+ hasher.update(await Bun.file(path).arrayBuffer());
189
+ const actual = hasher.digest("hex");
190
+ if (actual.toLowerCase() !== expected.toLowerCase()) {
191
+ rmSync(path, { force: true });
192
+ throw new Error(`sha256 mismatch (expected ${expected}, got ${actual})`);
193
+ }
194
+ }
195
+
196
+ /** Shallow-first search for a file named `name` under `dir`. */
197
+ async function findFile(dir: string, name: string): Promise<string | null> {
198
+ const entries = await readdir(dir, { withFileTypes: true });
199
+ for (const e of entries) {
200
+ if (e.isFile() && e.name === name) return join(dir, e.name);
201
+ }
202
+ for (const e of entries) {
203
+ if (e.isDirectory()) {
204
+ const hit = await findFile(join(dir, e.name), name);
205
+ if (hit) return hit;
206
+ }
207
+ }
208
+ return null;
209
+ }
210
+
211
+ function readState(): State | null {
212
+ try {
213
+ return JSON.parse(readFileSync(STATE_FILE, "utf8")) as State;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+
219
+ function writeState(state: State): void {
220
+ mkdirSync(CACHE_ROOT, { recursive: true });
221
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
222
+ }
package/src/cli/vscode.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawn, type Subprocess } from "bun";
2
+ import { resolveCodeBinary } from "./vscode-install";
2
3
 
3
4
  export interface VscodeServer {
4
5
  port: number;
@@ -23,6 +24,10 @@ export interface LaunchOptions {
23
24
  */
24
25
  export async function launchVscode(opts: LaunchOptions): Promise<VscodeServer> {
25
26
  const port = opts.port && opts.port > 0 ? opts.port : await freePort();
27
+ // NB: no `--default-folder` — current VS Code `serve-web` (≥1.x) doesn't
28
+ // accept it and exits with "unexpected argument". The workspace is opened by
29
+ // the browser instead, via the `?folder=` query the page appends to the
30
+ // iframe URL (see src/web/discovery.tsx). `cwd` below still roots the server.
26
31
  const args = [
27
32
  "serve-web",
28
33
  "--host",
@@ -31,14 +36,13 @@ export async function launchVscode(opts: LaunchOptions): Promise<VscodeServer> {
31
36
  String(port),
32
37
  "--server-base-path",
33
38
  opts.basePath,
34
- "--default-folder",
35
- opts.dir,
36
39
  "--without-connection-token",
37
40
  "--accept-server-license-terms",
38
41
  ];
39
42
 
40
- console.log(`[codehost] launching: code ${args.join(" ")}`);
41
- const proc = spawn(["code", ...args], {
43
+ const codeBin = await resolveCodeBinary();
44
+ console.log(`[codehost] launching: ${codeBin} ${args.join(" ")}`);
45
+ const proc = spawn([codeBin, ...args], {
42
46
  cwd: opts.dir,
43
47
  stdout: "inherit",
44
48
  stderr: "inherit",
@@ -0,0 +1,104 @@
1
+ // Shared helpers for GitHub-shaped deep links and matching them to a daemon.
2
+ // Used by the web resolver (src/web) and conceptually mirrors the daemon's
3
+ // repo identity (src/cli/git.ts).
4
+
5
+ import type { PeerInfo, PeerMeta } from "./signaling";
6
+
7
+ export const DEFAULT_LAYOUT = "{owner}/{repo}/tree/{branch}";
8
+
9
+ export interface RepoTarget {
10
+ provider: "gh";
11
+ owner: string;
12
+ name: string;
13
+ /** Branch from the deep link, if present. */
14
+ branch?: string;
15
+ }
16
+
17
+ /** A direct folder mount address: `/dev/<absolute-fs-path>`. */
18
+ export interface DevTarget {
19
+ path: string;
20
+ }
21
+
22
+ export type DeepLink =
23
+ | { type: "repo"; target: RepoTarget }
24
+ | { type: "dev"; target: DevTarget }
25
+ | null;
26
+
27
+ /**
28
+ * Parse a deep-link pathname:
29
+ * /gh/<owner>/<repo> -> repo target (default branch)
30
+ * /gh/<owner>/<repo>/tree/<branch> -> repo target (branch; may contain slashes)
31
+ * /dev/<fs-path> -> direct folder mount
32
+ * Anything else -> null (normal app).
33
+ */
34
+ export function parseDeepLink(pathname: string): DeepLink {
35
+ const clean = pathname.replace(/\/+$/, "");
36
+ const gh = clean.match(/^\/gh\/([^/]+)\/([^/]+)(?:\/tree\/(.+))?$/);
37
+ if (gh) {
38
+ return {
39
+ type: "repo",
40
+ target: { provider: "gh", owner: gh[1], name: gh[2], branch: gh[3] },
41
+ };
42
+ }
43
+ const dev = clean.match(/^\/dev\/(.+)$/);
44
+ if (dev) {
45
+ return { type: "dev", target: { path: `/${dev[1].replace(/^\/+/, "")}` } };
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /** Normalized repo key, e.g. "gh/owner/repo". */
51
+ export function repoKey(t: Pick<RepoTarget, "owner" | "name">): string {
52
+ return `gh/${t.owner}/${t.name}`;
53
+ }
54
+
55
+ /** Fill a layout template from a repo target (default branch -> "main"). */
56
+ export function fillLayout(layout: string, t: RepoTarget): string {
57
+ return layout
58
+ .replace(/\{owner\}/g, t.owner)
59
+ .replace(/\{repo\}/g, t.name)
60
+ .replace(/\{branch\}/g, t.branch || "main");
61
+ }
62
+
63
+ export interface Resolution {
64
+ peerId: string;
65
+ /** Folder to open via ?folder= (root kind); undefined opens the repo as-is. */
66
+ folder?: string;
67
+ }
68
+
69
+ /**
70
+ * Pick the best live server for a repo deep link. Prefers an exact `repo`
71
+ * daemon; otherwise falls back to a `root` daemon that can open the subfolder.
72
+ * Returns null if nothing matches.
73
+ */
74
+ export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Resolution | null {
75
+ const key = repoKey(target);
76
+ const repoMatch = servers.find(
77
+ (s) => s.meta?.kind !== "root" && s.meta?.repo === key && branchOk(s.meta, target),
78
+ );
79
+ if (repoMatch) return { peerId: repoMatch.peerId };
80
+
81
+ const root = servers.find((s) => s.meta?.kind === "root");
82
+ if (root && root.meta) {
83
+ const folder = `${trimSlash(root.meta.cwd)}/${fillLayout(root.meta.layout || DEFAULT_LAYOUT, target)}`;
84
+ return { peerId: root.peerId, folder };
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /** Pick a `dev`/repo server whose served cwd matches a /dev/<path> target. */
90
+ export function resolveDevTarget(servers: PeerInfo[], target: DevTarget): Resolution | null {
91
+ const want = trimSlash(target.path);
92
+ const hit = servers.find((s) => s.meta && trimSlash(s.meta.cwd) === want);
93
+ return hit ? { peerId: hit.peerId } : null;
94
+ }
95
+
96
+ function branchOk(meta: PeerMeta, target: RepoTarget): boolean {
97
+ // No branch requested, or the server doesn't report one -> accept; else exact.
98
+ if (!target.branch || !meta.branch) return true;
99
+ return meta.branch === target.branch;
100
+ }
101
+
102
+ function trimSlash(p: string): string {
103
+ return p.replace(/\/+$/, "");
104
+ }
@@ -4,14 +4,30 @@
4
4
 
5
5
  export type Role = "server" | "viewer";
6
6
 
7
- /** Metadata a `codehost serve` daemon advertises about itself. */
7
+ /** Metadata a `codehost serve`/`dev` daemon advertises about itself. */
8
8
  export interface PeerMeta {
9
9
  /** Human label, defaults to hostname. */
10
10
  name: string;
11
- /** Directory the VS Code instance is serving. */
11
+ /** Directory the VS Code instance is serving (the repo dir, or the root). */
12
12
  cwd: string;
13
13
  /** Hostname of the machine running the daemon. */
14
14
  host: string;
15
+ /**
16
+ * "repo": serves a single folder (`codehost dev`), git-identified when possible.
17
+ * "root": serves a workspace root (`codehost serve`) whose repos live under it.
18
+ * Absent is treated as "repo" for backward compatibility.
19
+ */
20
+ kind?: "repo" | "root";
21
+ /** repo kind: normalized identity, e.g. "gh/snomiao/codehost". */
22
+ repo?: string;
23
+ /** repo kind: current branch, e.g. "main". */
24
+ branch?: string;
25
+ /**
26
+ * root kind: on-disk layout of repos under `cwd`, as a template filled from a
27
+ * deep link — default "{owner}/{repo}/tree/{branch}". The opened folder is
28
+ * `cwd + "/" + fill(layout, target)`.
29
+ */
30
+ layout?: string;
15
31
  }
16
32
 
17
33
  export interface PeerInfo {
package/src/web/config.ts CHANGED
@@ -3,19 +3,56 @@
3
3
  // (vite on :5173) it talks to `wrangler dev` on :8787. Override either with
4
4
  // localStorage key "codehost.signal".
5
5
 
6
+ /** The signaling host for a given page host, e.g. signal.codehost.dev. */
7
+ function signalHost(hostname: string): string {
8
+ return `signal.${hostname.replace(/^www\./, "")}`;
9
+ }
10
+
6
11
  function defaultSignalUrl(): string {
7
- if (typeof window === "undefined") return "wss://signal.codehost.dev";
8
- const { hostname, protocol } = window.location;
12
+ // Read location off globalThis so this module also type-checks in the Service
13
+ // Worker build (webworker lib, no `window`). In a worker there's no signaling
14
+ // anyway; getSignalUrl is page-only and tree-shaken out of the SW bundle.
15
+ const loc = (globalThis as { location?: { hostname: string; protocol: string } }).location;
16
+ if (!loc) return "wss://signal.codehost.dev";
17
+ const { hostname, protocol } = loc;
9
18
  const isLocal = hostname === "localhost" || hostname === "127.0.0.1";
10
19
  if (isLocal) return "ws://localhost:8787";
11
20
  const wsProto = protocol === "https:" ? "wss:" : "ws:";
12
- return `${wsProto}//signal.${hostname.replace(/^www\./, "")}`;
21
+ return `${wsProto}//${signalHost(hostname)}`;
13
22
  }
14
23
 
15
24
  export function getSignalUrl(): string {
16
- if (typeof localStorage !== "undefined") {
17
- const override = localStorage.getItem("codehost.signal");
18
- if (override) return override;
19
- }
25
+ const ls = (globalThis as { localStorage?: { getItem(k: string): string | null } }).localStorage;
26
+ const override = ls?.getItem("codehost.signal");
27
+ if (override) return override;
20
28
  return defaultSignalUrl();
21
29
  }
30
+
31
+ // --- VS Code CDN proxy ---------------------------------------------------
32
+ // Microsoft's VS Code product CDN sends no CORS headers, so the workbench's
33
+ // cross-origin fetches (e.g. https://main.vscode-cdn.net/extensions/chat.json)
34
+ // are blocked when VS Code runs inside our iframe. The Service Worker rewrites
35
+ // those requests to the signaling Worker's /cdn route, which re-serves them
36
+ // with Access-Control-Allow-Origin: *. See docs/vscode-cdn-proxy.md.
37
+
38
+ /** Hostname suffix for the CDN we proxy. The Worker is the authoritative gate;
39
+ * this lets the SW know which cross-origin requests to rewrite. */
40
+ export const VSCODE_CDN_SUFFIX = ".vscode-cdn.net";
41
+
42
+ export function isProxiableCdnHost(hostname: string): boolean {
43
+ return hostname.endsWith(VSCODE_CDN_SUFFIX);
44
+ }
45
+
46
+ /**
47
+ * HTTPS base of the signaling host for the current page, e.g.
48
+ * https://signal.codehost.dev. The SW rewrites blocked CDN requests to
49
+ * `${base}/cdn/<host>/<path>`. Derived from the live host (never hardcoded) so a
50
+ * self-hoster serving the page + Worker on their own domain is proxied by their
51
+ * own Worker at signal.<their-domain>.
52
+ */
53
+ export function cdnProxyBase(hostname: string, protocol: string): string {
54
+ const isLocal = hostname === "localhost" || hostname === "127.0.0.1";
55
+ if (isLocal) return "http://localhost:8787";
56
+ const httpProto = protocol === "https:" ? "https:" : "http:";
57
+ return `${httpProto}//${signalHost(hostname)}`;
58
+ }
@@ -7,13 +7,54 @@ import { RtcClient } from "./rtc-client";
7
7
  import { getSignalUrl } from "./config";
8
8
  import { registerTunnelHost } from "./tunnel-host";
9
9
  import { connBroker } from "./conn-broker";
10
+ import {
11
+ type DeepLink,
12
+ parseDeepLink,
13
+ repoKey,
14
+ resolveDevTarget,
15
+ resolveRepoTarget,
16
+ } from "../shared/repo";
17
+ import { addRoom, historyFor, recordConnection } from "./history";
10
18
 
11
19
  const TOKEN_KEY = "codehost.token";
12
20
 
13
21
  type ConnState = "idle" | "connecting" | "connected" | "failed";
14
22
 
23
+ /**
24
+ * Read a room token handed in the URL fragment as `#t=<token>` (what the CLI
25
+ * prints/opens after `setup`/`serve`). The page is static, so the fragment
26
+ * never reaches the server — a safe place for the shared secret. Everything
27
+ * after `#t=` is the token (URL-encoded by the CLI); returns "" if absent.
28
+ */
29
+ function tokenFromHash(): string {
30
+ const m = window.location.hash.match(/^#t=(.+)$/);
31
+ if (!m) return "";
32
+ try {
33
+ return decodeURIComponent(m[1]).trim();
34
+ } catch {
35
+ return m[1].trim();
36
+ }
37
+ }
38
+
39
+ /** Short label for the "looking for…" state from a deep link. */
40
+ function deepLinkLabel(dl: DeepLink): string | null {
41
+ if (!dl) return null;
42
+ return dl.type === "repo" ? `${dl.target.owner}/${dl.target.name}` : dl.target.path;
43
+ }
44
+
45
+ function folderQuery(folder?: string): string {
46
+ return folder ? `?folder=${encodeURIComponent(folder)}` : "";
47
+ }
48
+
15
49
  export function Discovery() {
16
- const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? "");
50
+ const [token, setToken] = useState(() => {
51
+ const fromHash = tokenFromHash();
52
+ if (fromHash && validateToken(fromHash).ok) {
53
+ localStorage.setItem(TOKEN_KEY, fromHash);
54
+ return fromHash;
55
+ }
56
+ return localStorage.getItem(TOKEN_KEY) ?? "";
57
+ });
17
58
  const [draft, setDraft] = useState(token);
18
59
  const [tokenError, setTokenError] = useState<string | null>(null);
19
60
  const [connected, setConnected] = useState(false);
@@ -30,6 +71,15 @@ export function Discovery() {
30
71
  const rtcRef = useRef<RtcClient | null>(null);
31
72
  const activePeerRef = useRef<string | null>(null);
32
73
 
74
+ // Deep-link resolution (/gh/<owner>/<repo>/... or /dev/<path>): parse once,
75
+ // auto-connect when a matching server appears, remember the opened folder.
76
+ const deepLinkRef = useRef<DeepLink>(parseDeepLink(window.location.pathname));
77
+ const resolvedRef = useRef(false);
78
+ // A valid token in the URL fragment enables single-server auto-connect.
79
+ const autoConnectRef = useRef(false);
80
+ const activeFolderRef = useRef<string | undefined>(undefined);
81
+ const [resolving, setResolving] = useState<string | null>(() => deepLinkLabel(deepLinkRef.current));
82
+
33
83
  // Register the Service Worker + connection broker once. The broker shares one
34
84
  // WebRTC connection per server across tabs; on owner failover it asks us to
35
85
  // reload the iframe so it reconnects through the new owner.
@@ -38,8 +88,27 @@ export function Discovery() {
38
88
  connBroker.onLost((peerId) => {
39
89
  if (peerId !== activePeerRef.current) return;
40
90
  setIframeSrc(null);
41
- setTimeout(() => setIframeSrc(`/vs/${peerId}/`), 400);
91
+ const folder = activeFolderRef.current;
92
+ setTimeout(() => setIframeSrc(`/vs/${peerId}/${folderQuery(folder)}`), 400);
42
93
  });
94
+ // A valid token in the URL fragment (#t=<token>) seeds the room and turns on
95
+ // single-server auto-connect; consume it from the address bar afterwards so
96
+ // the secret isn't left visible or re-applied on a manual reload.
97
+ const urlToken = tokenFromHash();
98
+ if (urlToken && validateToken(urlToken).ok) {
99
+ autoConnectRef.current = true;
100
+ if (window.location.hash) {
101
+ history.replaceState(null, "", window.location.pathname + window.location.search);
102
+ }
103
+ }
104
+
105
+ // For a repo deep link, adopt the room that last served it so it resolves
106
+ // without re-entering a token.
107
+ const dl = deepLinkRef.current;
108
+ if (dl?.type === "repo") {
109
+ const h = historyFor(repoKey(dl.target));
110
+ if (h?.token) setToken(h.token);
111
+ }
43
112
  }, []);
44
113
 
45
114
  useEffect(() => {
@@ -48,9 +117,16 @@ export function Discovery() {
48
117
  url: getSignalUrl(),
49
118
  token,
50
119
  role: "viewer",
51
- onOpen: () => setConnected(true),
120
+ onOpen: () => {
121
+ setConnected(true);
122
+ addRoom(token);
123
+ },
52
124
  onClose: () => setConnected(false),
53
- onPeers: (peers) => setServers(peers.filter((p) => p.role === "server")),
125
+ onPeers: (peers) => {
126
+ const list = peers.filter((p) => p.role === "server");
127
+ setServers(list);
128
+ void tryAutoConnect(list);
129
+ },
54
130
  onSignal: (from, data) => {
55
131
  if (from === activePeerRef.current) void rtcRef.current?.handleSignal(data);
56
132
  },
@@ -85,7 +161,7 @@ export function Discovery() {
85
161
  setToken(t);
86
162
  }
87
163
 
88
- async function connectTo(server: PeerInfo) {
164
+ async function connectTo(server: PeerInfo, folder?: string) {
89
165
  const client = clientRef.current;
90
166
  if (!client) return;
91
167
 
@@ -128,12 +204,55 @@ export function Discovery() {
128
204
  try {
129
205
  await connBroker.connect(server.peerId, establish);
130
206
  setConnState("connected");
131
- setIframeSrc(`/vs/${server.peerId}/`);
207
+ // The daemon no longer sets a default folder (current VS Code serve-web
208
+ // dropped that flag), so open the served workspace from here: an explicit
209
+ // deep-link folder if we have one, else the server's reported cwd.
210
+ const openFolder = folder ?? server.meta?.cwd;
211
+ activeFolderRef.current = openFolder;
212
+ setIframeSrc(`/vs/${server.peerId}/${folderQuery(openFolder)}`);
213
+ setResolving(null);
214
+ recordConnect(server, openFolder);
132
215
  } catch {
133
216
  setConnState("failed");
134
217
  }
135
218
  }
136
219
 
220
+ // Deep-link auto-connect: when servers arrive, pick the best match (exact repo
221
+ // daemon, else a root daemon's subfolder) and open it once.
222
+ async function tryAutoConnect(list: PeerInfo[]) {
223
+ if (resolvedRef.current) return;
224
+ const dl = deepLinkRef.current;
225
+ if (dl) {
226
+ const res = dl.type === "repo" ? resolveRepoTarget(list, dl.target) : resolveDevTarget(list, dl.target);
227
+ if (!res) return;
228
+ const server = list.find((s) => s.peerId === res.peerId);
229
+ if (!server) return;
230
+ resolvedRef.current = true;
231
+ await connectTo(server, res.folder);
232
+ return;
233
+ }
234
+ // No deep link, but a token arrived via the URL: open the room's server
235
+ // straight away when there's exactly one; with several, leave the picker.
236
+ if (autoConnectRef.current && list.length === 1) {
237
+ resolvedRef.current = true;
238
+ await connectTo(list[0]);
239
+ }
240
+ }
241
+
242
+ function recordConnect(server: PeerInfo, folder?: string) {
243
+ const base = {
244
+ token,
245
+ peerId: server.peerId,
246
+ kind: server.meta?.kind,
247
+ name: server.meta?.name,
248
+ host: server.meta?.host,
249
+ lastConnected: Date.now(),
250
+ };
251
+ if (server.meta?.repo) recordConnection(server.meta.repo, { ...base, folder });
252
+ const dl = deepLinkRef.current;
253
+ if (dl?.type === "repo") recordConnection(repoKey(dl.target), { ...base, folder });
254
+ }
255
+
137
256
  function disconnect() {
138
257
  rtcRef.current?.close();
139
258
  rtcRef.current = null;
@@ -178,6 +297,12 @@ export function Discovery() {
178
297
  </header>
179
298
 
180
299
  <main style={styles.main}>
300
+ {resolving && (
301
+ <p style={{ color: "#dcb67a", marginBottom: 12 }}>
302
+ Looking for <code style={styles.code}>{resolving}</code> in your rooms…{" "}
303
+ {token ? "waiting for a live server" : "enter the room's token below"}.
304
+ </p>
305
+ )}
181
306
  <form onSubmit={applyToken} style={styles.tokenForm}>
182
307
  <label style={styles.label}>Token</label>
183
308
  <input