codehost 0.20.5 → 0.21.0
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.
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/cli/approver.ts +212 -0
- package/src/cli/commands/dev.ts +19 -0
- package/src/cli/commands/open.ts +95 -0
- package/src/cli/commands/serve.ts +19 -0
- package/src/cli/commands/setup.ts +17 -1
- package/src/cli/daemonize.ts +8 -0
- package/src/cli/index.ts +2 -0
- package/src/cli/rtc-daemon.ts +70 -30
- package/src/cli/run-server.ts +49 -4
- package/src/shared/repo.ts +3 -3
- package/src/shared/rtc.ts +13 -1
- package/src/shared/signaling-client.ts +2 -2
- package/src/shared/signaling.ts +39 -7
- package/src/web/discovery.tsx +147 -16
- package/src/web/room-client.ts +2 -2
- package/worker/room.ts +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.21.0](https://github.com/snomiao/codehost/compare/v0.20.5...v0.21.0) (2026-06-13)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* room roster, host approval/kick, `codehost open`; rename viewer→client ([f3a2a82](https://github.com/snomiao/codehost/commit/f3a2a8265aa8b1adf95ad764fb6d30d9561c6e3c))
|
|
7
|
+
|
|
1
8
|
## [0.20.5](https://github.com/snomiao/codehost/compare/v0.20.4...v0.20.5) (2026-06-12)
|
|
2
9
|
|
|
3
10
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { createInterface, type Interface } from "node:readline";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Host-side admission policy for clients (connecting browsers — each gets a full
|
|
5
|
+
* VS Code session, so this is a real gate, not just UX).
|
|
6
|
+
* - "auto": admit everyone with the token (default; the token is the gate).
|
|
7
|
+
* - "confirm": hold each new client until the host approves it at the terminal,
|
|
8
|
+
* unless its label matches a pre-approved `--allow` pattern.
|
|
9
|
+
*/
|
|
10
|
+
export type ApprovePolicy = "auto" | "confirm";
|
|
11
|
+
|
|
12
|
+
export interface ApproverOptions {
|
|
13
|
+
policy: ApprovePolicy;
|
|
14
|
+
/** Case-insensitive label substrings auto-approved even under "confirm". */
|
|
15
|
+
allow: string[];
|
|
16
|
+
/** Tear down a live client's connection (kick). */
|
|
17
|
+
kick: (clientId: string) => void;
|
|
18
|
+
/** Emit a "pending approval" hint to a client that's now waiting on a human. */
|
|
19
|
+
notifyPending: (clientId: string) => void;
|
|
20
|
+
/** Whether stdin is an interactive terminal (defaults to process.stdin.isTTY). */
|
|
21
|
+
isTTY?: boolean;
|
|
22
|
+
/** Injected input/output for tests; default to the real process streams. */
|
|
23
|
+
input?: NodeJS.ReadableStream;
|
|
24
|
+
output?: NodeJS.WritableStream;
|
|
25
|
+
log?: (msg: string) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Pending {
|
|
29
|
+
clientId: string;
|
|
30
|
+
label: string;
|
|
31
|
+
resolve: (ok: boolean) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Decides whether to admit clients and drives an interactive terminal console
|
|
36
|
+
* for approve / deny / kick / list. Self-contained (no WebRTC deps) so the
|
|
37
|
+
* daemon stays thin and this stays unit-testable.
|
|
38
|
+
*/
|
|
39
|
+
export class Approver {
|
|
40
|
+
private opts: ApproverOptions;
|
|
41
|
+
private isTTY: boolean;
|
|
42
|
+
private out: NodeJS.WritableStream;
|
|
43
|
+
private log: (msg: string) => void;
|
|
44
|
+
|
|
45
|
+
/** Labels approved with "a(lways)" this session — auto-admit on sight. */
|
|
46
|
+
private sessionAllow = new Set<string>();
|
|
47
|
+
/** Live clients (admitted + connected), for `list` / `kick`. */
|
|
48
|
+
private active = new Map<string, string>();
|
|
49
|
+
/** Approvals waiting on the host; the head is the one being asked. */
|
|
50
|
+
private queue: Pending[] = [];
|
|
51
|
+
private asking = false;
|
|
52
|
+
private rl: Interface | null = null;
|
|
53
|
+
|
|
54
|
+
constructor(opts: ApproverOptions) {
|
|
55
|
+
this.opts = opts;
|
|
56
|
+
this.isTTY = opts.isTTY ?? Boolean(process.stdin.isTTY);
|
|
57
|
+
this.out = opts.output ?? process.stdout;
|
|
58
|
+
this.log = opts.log ?? ((m) => console.log(m));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Announce the active policy once at startup. */
|
|
62
|
+
banner(): void {
|
|
63
|
+
if (this.opts.policy === "auto") return;
|
|
64
|
+
if (this.isTTY) {
|
|
65
|
+
this.log("[codehost] approval: confirm — new clients wait for your OK. Commands: list · kick <n>");
|
|
66
|
+
} else {
|
|
67
|
+
this.log(
|
|
68
|
+
"[codehost] approval: confirm, but no terminal is attached — clients can't be approved " +
|
|
69
|
+
"interactively and will be denied. Use --approve auto or --allow <label> for a daemon.",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (this.opts.allow.length) this.log(`[codehost] auto-approving labels matching: ${this.opts.allow.join(", ")}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Resolve true to admit `clientId`, false to deny. Called once per offer. */
|
|
76
|
+
admit(clientId: string, label: string): Promise<boolean> {
|
|
77
|
+
if (this.opts.policy === "auto" || this.preApproved(label)) return Promise.resolve(true);
|
|
78
|
+
|
|
79
|
+
// Confirm mode but no terminal to ask at: deny safely.
|
|
80
|
+
if (!this.isTTY) {
|
|
81
|
+
this.log(`[codehost] denied "${label}" (${short(clientId)}): confirm mode needs a terminal`);
|
|
82
|
+
return Promise.resolve(false);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.opts.notifyPending(clientId);
|
|
86
|
+
return new Promise<boolean>((resolve) => {
|
|
87
|
+
this.queue.push({ clientId, label, resolve });
|
|
88
|
+
this.pumpQueue();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Mark a client live once its tunnel is bridged (for the console roster). */
|
|
93
|
+
onConnected(clientId: string, label: string): void {
|
|
94
|
+
this.active.set(clientId, label);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Drop a client from the roster and resolve any in-flight approval as denied. */
|
|
98
|
+
onDisconnected(clientId: string): void {
|
|
99
|
+
this.active.delete(clientId);
|
|
100
|
+
const idx = this.queue.findIndex((p) => p.clientId === clientId);
|
|
101
|
+
if (idx >= 0) {
|
|
102
|
+
const [p] = this.queue.splice(idx, 1);
|
|
103
|
+
p.resolve(false);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private preApproved(label: string): boolean {
|
|
108
|
+
if (this.sessionAllow.has(label)) return true;
|
|
109
|
+
const l = label.toLowerCase();
|
|
110
|
+
return this.opts.allow.some((p) => p && l.includes(p.toLowerCase()));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---- interactive console ----
|
|
114
|
+
|
|
115
|
+
/** Start the readline console (no-op unless confirm + TTY). */
|
|
116
|
+
start(): void {
|
|
117
|
+
if (this.opts.policy !== "confirm" || !this.isTTY || this.rl) return;
|
|
118
|
+
this.rl = createInterface({ input: this.opts.input ?? process.stdin, output: this.out });
|
|
119
|
+
this.rl.on("line", (line) => this.onLine(line.trim()));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
stop(): void {
|
|
123
|
+
this.rl?.close();
|
|
124
|
+
this.rl = null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private pumpQueue(): void {
|
|
128
|
+
if (this.asking || this.queue.length === 0) return;
|
|
129
|
+
this.asking = true;
|
|
130
|
+
const { label, clientId } = this.queue[0];
|
|
131
|
+
this.out.write(`\n[codehost] "${label}" (${short(clientId)}) wants to connect. Approve? [y/N/a=always] `);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private onLine(line: string): void {
|
|
135
|
+
if (this.asking) {
|
|
136
|
+
this.answer(line);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.command(line);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private answer(line: string): void {
|
|
143
|
+
const pending = this.queue.shift();
|
|
144
|
+
this.asking = false;
|
|
145
|
+
if (!pending) return;
|
|
146
|
+
const a = line.toLowerCase();
|
|
147
|
+
const always = a === "a" || a === "always";
|
|
148
|
+
const yes = always || a === "y" || a === "yes";
|
|
149
|
+
if (always) this.sessionAllow.add(pending.label);
|
|
150
|
+
this.log(`[codehost] ${yes ? "approved" : "denied"} "${pending.label}" (${short(pending.clientId)})`);
|
|
151
|
+
pending.resolve(yes);
|
|
152
|
+
this.pumpQueue();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private command(line: string): void {
|
|
156
|
+
if (!line) return;
|
|
157
|
+
const [cmd, ...rest] = line.split(/\s+/);
|
|
158
|
+
const arg = rest.join(" ");
|
|
159
|
+
switch (cmd) {
|
|
160
|
+
case "l":
|
|
161
|
+
case "ls":
|
|
162
|
+
case "list":
|
|
163
|
+
this.printList();
|
|
164
|
+
break;
|
|
165
|
+
case "k":
|
|
166
|
+
case "kick":
|
|
167
|
+
this.doKick(arg);
|
|
168
|
+
break;
|
|
169
|
+
case "h":
|
|
170
|
+
case "help":
|
|
171
|
+
case "?":
|
|
172
|
+
this.log("[codehost] commands: list · kick <n|id> · help");
|
|
173
|
+
break;
|
|
174
|
+
default:
|
|
175
|
+
this.log(`[codehost] unknown command "${cmd}" — try: list · kick <n|id>`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private printList(): void {
|
|
180
|
+
const entries = [...this.active.entries()];
|
|
181
|
+
if (entries.length === 0) {
|
|
182
|
+
this.log("[codehost] no clients connected");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
this.log("[codehost] connected clients:");
|
|
186
|
+
entries.forEach(([id, label], i) => this.log(` ${i + 1}. ${label} (${short(id)})`));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private doKick(arg: string): void {
|
|
190
|
+
const id = this.resolveClient(arg);
|
|
191
|
+
if (!id) {
|
|
192
|
+
this.log(`[codehost] no connected client matches "${arg}" — see: list`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const label = this.active.get(id) ?? "client";
|
|
196
|
+
this.log(`[codehost] kicking "${label}" (${short(id)})`);
|
|
197
|
+
this.opts.kick(id);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Resolve a `kick` argument: 1-based index from `list`, or a peerId prefix. */
|
|
201
|
+
private resolveClient(arg: string): string | null {
|
|
202
|
+
if (!arg) return null;
|
|
203
|
+
const entries = [...this.active.keys()];
|
|
204
|
+
const n = Number(arg);
|
|
205
|
+
if (Number.isInteger(n) && n >= 1 && n <= entries.length) return entries[n - 1];
|
|
206
|
+
return entries.find((id) => id.startsWith(arg) || short(id) === arg) ?? null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function short(id: string): string {
|
|
211
|
+
return id.slice(0, 8);
|
|
212
|
+
}
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { hostname } from "node:os";
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import type { CommandModule } from "yargs";
|
|
4
4
|
import type { PeerMeta } from "../../shared/signaling";
|
|
5
|
+
import type { ApprovePolicy } from "../approver";
|
|
5
6
|
import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
|
|
6
7
|
import { ensureHostId } from "../config";
|
|
7
8
|
import { launchServeDaemon } from "../daemonize";
|
|
@@ -21,6 +22,8 @@ interface DevArgs {
|
|
|
21
22
|
daemon: boolean;
|
|
22
23
|
standalone: boolean;
|
|
23
24
|
port?: number;
|
|
25
|
+
approve: string;
|
|
26
|
+
allow: string[];
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
export const devCommand: CommandModule<{}, DevArgs> = {
|
|
@@ -63,6 +66,18 @@ export const devCommand: CommandModule<{}, DevArgs> = {
|
|
|
63
66
|
.option("port", {
|
|
64
67
|
describe: "Fixed port for the local VS Code server (default: ephemeral)",
|
|
65
68
|
type: "number",
|
|
69
|
+
})
|
|
70
|
+
.option("approve", {
|
|
71
|
+
describe: "Client admission: 'auto' (anyone with the token) or 'confirm' (approve each at the terminal)",
|
|
72
|
+
type: "string",
|
|
73
|
+
choices: ["auto", "confirm"],
|
|
74
|
+
default: "auto",
|
|
75
|
+
})
|
|
76
|
+
.option("allow", {
|
|
77
|
+
describe: "Under --approve confirm, auto-approve clients whose label matches (repeatable)",
|
|
78
|
+
type: "string",
|
|
79
|
+
array: true,
|
|
80
|
+
default: [],
|
|
66
81
|
}) as any,
|
|
67
82
|
handler: async (argv) => {
|
|
68
83
|
argv.token = argv.token.trim();
|
|
@@ -107,6 +122,8 @@ export const devCommand: CommandModule<{}, DevArgs> = {
|
|
|
107
122
|
name: argv.name,
|
|
108
123
|
port: argv.port,
|
|
109
124
|
host,
|
|
125
|
+
approve: argv.approve,
|
|
126
|
+
allow: argv.allow,
|
|
110
127
|
});
|
|
111
128
|
if (ok) announceConnect(argv.token);
|
|
112
129
|
process.exit(ok ? 0 : 1);
|
|
@@ -132,6 +149,8 @@ export const devCommand: CommandModule<{}, DevArgs> = {
|
|
|
132
149
|
signal: argv.signal,
|
|
133
150
|
meta,
|
|
134
151
|
label: `serving ${dir}`,
|
|
152
|
+
approve: argv.approve as ApprovePolicy,
|
|
153
|
+
allow: argv.allow,
|
|
135
154
|
launch: async (basePath) => {
|
|
136
155
|
const v = await launchVscode({ dir, basePath, port: argv.port });
|
|
137
156
|
return { port: v.port, stop: v.stop };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import type { CommandModule } from "yargs";
|
|
3
|
+
import { readConfig } from "../config";
|
|
4
|
+
import { repoIdentity } from "../git";
|
|
5
|
+
import { GITHUB_HOST, gitUrlToPath, shareableDeepLink } from "../../shared/repo";
|
|
6
|
+
import { PAGE_URL, openBrowser } from "../open-url";
|
|
7
|
+
|
|
8
|
+
interface OpenArgs {
|
|
9
|
+
target?: string;
|
|
10
|
+
token?: string;
|
|
11
|
+
anon: boolean;
|
|
12
|
+
page: string;
|
|
13
|
+
print: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const openCommand: CommandModule<{}, OpenArgs> = {
|
|
17
|
+
command: "open [target]",
|
|
18
|
+
describe: "Open a codehost deep link in the browser (defaults to the repo in the current directory)",
|
|
19
|
+
builder: (y) =>
|
|
20
|
+
y
|
|
21
|
+
.positional("target", {
|
|
22
|
+
describe:
|
|
23
|
+
"What to open: a repo (owner/repo, a git URL, or gh/git/host/dev path) or a full URL. Omit to use the git repo in the current directory.",
|
|
24
|
+
type: "string",
|
|
25
|
+
})
|
|
26
|
+
.option("token", {
|
|
27
|
+
alias: "t",
|
|
28
|
+
describe: "Token to embed for auto-connect (defaults to the saved room token)",
|
|
29
|
+
type: "string",
|
|
30
|
+
})
|
|
31
|
+
.option("anon", {
|
|
32
|
+
describe: "Open without embedding a token",
|
|
33
|
+
type: "boolean",
|
|
34
|
+
default: false,
|
|
35
|
+
})
|
|
36
|
+
.option("page", { describe: "Page URL to open against", type: "string", default: PAGE_URL })
|
|
37
|
+
.option("print", {
|
|
38
|
+
describe: "Print the URL instead of opening a browser",
|
|
39
|
+
type: "boolean",
|
|
40
|
+
default: false,
|
|
41
|
+
}) as any,
|
|
42
|
+
handler: async (argv) => {
|
|
43
|
+
const url = buildUrl(argv);
|
|
44
|
+
console.log(`[codehost] ${url}`);
|
|
45
|
+
if (argv.print) return;
|
|
46
|
+
openBrowser(url);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the URL to open. A full non-git URL is used verbatim; otherwise we
|
|
52
|
+
* build a codehost deep-link path (reusing the same helpers the page resolves
|
|
53
|
+
* with) and, unless `--anon`, append the room token in the `#t=<token>` fragment
|
|
54
|
+
* so the page auto-connects (the fragment stays in the browser). With no target
|
|
55
|
+
* we derive the path from the git repo in the current directory.
|
|
56
|
+
*/
|
|
57
|
+
function buildUrl(argv: OpenArgs): string {
|
|
58
|
+
const target = argv.target?.trim();
|
|
59
|
+
|
|
60
|
+
// A full non-repo URL is opened as-is. (A repo URL like
|
|
61
|
+
// https://github.com/o/r is turned into a deep link below.)
|
|
62
|
+
if (target && /^https?:\/\//i.test(target) && !gitUrlToPath(target)) return target;
|
|
63
|
+
|
|
64
|
+
const path = target ? pathFromTarget(target) : pathFromCwd();
|
|
65
|
+
const base = `${argv.page.replace(/\/+$/, "")}${path}`;
|
|
66
|
+
|
|
67
|
+
const token = argv.anon ? undefined : (argv.token?.trim() || readConfig().token);
|
|
68
|
+
return token ? `${base}#t=${encodeURIComponent(token)}` : base;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Map a positional target to a deep-link path (always starts with `/`). */
|
|
72
|
+
function pathFromTarget(target: string): string {
|
|
73
|
+
const clean = target.replace(/^\/+/, "");
|
|
74
|
+
// Already a known deep-link shape — pass through.
|
|
75
|
+
if (/^(gh|git|host|dev)\//.test(clean)) return `/${clean}`;
|
|
76
|
+
// A git URL / scp / host/owner/repo (dotted host), incl. /tree/<branch>.
|
|
77
|
+
const fromUrl = gitUrlToPath(target);
|
|
78
|
+
if (fromUrl) return fromUrl;
|
|
79
|
+
// Bare `owner/repo[/tree/branch]` -> GitHub deep link.
|
|
80
|
+
const m = clean.match(/^([^/]+)\/([^/]+)(?:\/tree\/(.+))?$/);
|
|
81
|
+
if (m) return shareableDeepLink({ repo: `${GITHUB_HOST}/${m[1]}/${m[2]}`, branch: m[3] }) ?? "/";
|
|
82
|
+
// Anything else (e.g. a folder path) -> legacy host-agnostic folder mount.
|
|
83
|
+
return `/dev/${clean}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Derive a deep-link path from the git repo in the current directory. */
|
|
87
|
+
function pathFromCwd(): string {
|
|
88
|
+
const { repo, branch } = repoIdentity(resolve(process.cwd()));
|
|
89
|
+
const path = repo ? shareableDeepLink({ repo, branch }) : null;
|
|
90
|
+
if (!path) {
|
|
91
|
+
console.error("[codehost] no git repo found here; opening the codehost home page");
|
|
92
|
+
return "/";
|
|
93
|
+
}
|
|
94
|
+
return path;
|
|
95
|
+
}
|
|
@@ -3,6 +3,7 @@ import { homedir, hostname } from "node:os";
|
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import type { CommandModule } from "yargs";
|
|
5
5
|
import type { PeerMeta } from "../../shared/signaling";
|
|
6
|
+
import type { ApprovePolicy } from "../approver";
|
|
6
7
|
import { DEFAULT_LAYOUT, GITHUB_HOST, toPosixPath } from "../../shared/repo";
|
|
7
8
|
import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
|
|
8
9
|
import { defaultRoot, ensureHostId } from "../config";
|
|
@@ -66,6 +67,8 @@ interface ServeArgs {
|
|
|
66
67
|
signal: string;
|
|
67
68
|
daemon: boolean;
|
|
68
69
|
port?: number;
|
|
70
|
+
approve: string;
|
|
71
|
+
allow: string[];
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
@@ -102,6 +105,18 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
|
102
105
|
.option("port", {
|
|
103
106
|
describe: "Fixed port for the local VS Code server (default: ephemeral)",
|
|
104
107
|
type: "number",
|
|
108
|
+
})
|
|
109
|
+
.option("approve", {
|
|
110
|
+
describe: "Client admission: 'auto' (anyone with the token) or 'confirm' (approve each at the terminal)",
|
|
111
|
+
type: "string",
|
|
112
|
+
choices: ["auto", "confirm"],
|
|
113
|
+
default: "auto",
|
|
114
|
+
})
|
|
115
|
+
.option("allow", {
|
|
116
|
+
describe: "Under --approve confirm, auto-approve clients whose label matches (repeatable)",
|
|
117
|
+
type: "string",
|
|
118
|
+
array: true,
|
|
119
|
+
default: [],
|
|
105
120
|
}) as any,
|
|
106
121
|
handler: async (argv) => {
|
|
107
122
|
argv.token = argv.token.trim();
|
|
@@ -135,6 +150,8 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
|
135
150
|
name: argv.name,
|
|
136
151
|
port: argv.port,
|
|
137
152
|
host,
|
|
153
|
+
approve: argv.approve,
|
|
154
|
+
allow: argv.allow,
|
|
138
155
|
});
|
|
139
156
|
if (ok) announceConnect(argv.token);
|
|
140
157
|
process.exit(ok ? 0 : 1);
|
|
@@ -208,6 +225,8 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
|
|
|
208
225
|
metaRefreshMs: 3_000,
|
|
209
226
|
watchFiles: [workspacesFile()],
|
|
210
227
|
plugins,
|
|
228
|
+
approve: argv.approve as ApprovePolicy,
|
|
229
|
+
allow: argv.allow,
|
|
211
230
|
label: `serving workspace root ${dir}`,
|
|
212
231
|
provision: { homeDir: dir, host: GITHUB_HOST },
|
|
213
232
|
launch: async (basePath) => {
|
|
@@ -19,6 +19,8 @@ interface SetupArgs {
|
|
|
19
19
|
name?: string;
|
|
20
20
|
signal: string;
|
|
21
21
|
port?: number;
|
|
22
|
+
approve: string;
|
|
23
|
+
allow: string[];
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export const setupCommand: CommandModule<{}, SetupArgs> = {
|
|
@@ -42,7 +44,19 @@ export const setupCommand: CommandModule<{}, SetupArgs> = {
|
|
|
42
44
|
})
|
|
43
45
|
.option("name", { describe: "Display name for this server (defaults to hostname)", type: "string" })
|
|
44
46
|
.option("signal", { describe: "Signaling server URL", type: "string", default: DEFAULT_SIGNAL_URL })
|
|
45
|
-
.option("port", { describe: "Fixed port for the local VS Code server", type: "number" })
|
|
47
|
+
.option("port", { describe: "Fixed port for the local VS Code server", type: "number" })
|
|
48
|
+
.option("approve", {
|
|
49
|
+
describe: "Client admission: 'auto' (anyone with the token) or 'confirm' (approve via --allow; the daemon has no terminal)",
|
|
50
|
+
type: "string",
|
|
51
|
+
choices: ["auto", "confirm"],
|
|
52
|
+
default: "auto",
|
|
53
|
+
})
|
|
54
|
+
.option("allow", {
|
|
55
|
+
describe: "Under --approve confirm, auto-approve clients whose label matches (repeatable)",
|
|
56
|
+
type: "string",
|
|
57
|
+
array: true,
|
|
58
|
+
default: [],
|
|
59
|
+
}) as any,
|
|
46
60
|
handler: async (argv) => {
|
|
47
61
|
// Explicit dir > a git cwd (serve THIS repo) > remembered root > ~/ws —
|
|
48
62
|
// so a bare `codehost setup` (e.g. from the installer) lands on a sane
|
|
@@ -99,6 +113,8 @@ export const setupCommand: CommandModule<{}, SetupArgs> = {
|
|
|
99
113
|
name: argv.name,
|
|
100
114
|
port: argv.port,
|
|
101
115
|
host,
|
|
116
|
+
approve: argv.approve,
|
|
117
|
+
allow: argv.allow,
|
|
102
118
|
});
|
|
103
119
|
if (!ok) process.exit(1);
|
|
104
120
|
|
package/src/cli/daemonize.ts
CHANGED
|
@@ -20,6 +20,10 @@ export interface ServeDaemonOptions {
|
|
|
20
20
|
port?: number;
|
|
21
21
|
/** Hostname, used as a label fallback. */
|
|
22
22
|
host: string;
|
|
23
|
+
/** Client admission policy ("auto" | "confirm"); propagated to the daemon. */
|
|
24
|
+
approve?: string;
|
|
25
|
+
/** Pre-approved client label patterns, propagated to the daemon. */
|
|
26
|
+
allow?: string[];
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export interface ServeDaemonResult {
|
|
@@ -79,6 +83,10 @@ function buildForegroundArgv(opts: ServeDaemonOptions): string[] {
|
|
|
79
83
|
// never collapse into register-with-the-host-daemon-and-exit (oxmgr would
|
|
80
84
|
// see an instant exit and thrash).
|
|
81
85
|
if ((opts.command ?? "serve") === "dev") parts.push("--standalone");
|
|
86
|
+
// A backgrounded daemon has no terminal, so "confirm" can only admit clients
|
|
87
|
+
// that match an --allow pattern; we still propagate it so that works.
|
|
88
|
+
if (opts.approve && opts.approve !== "auto") parts.push("--approve", opts.approve);
|
|
89
|
+
for (const pat of opts.allow ?? []) parts.push("--allow", pat);
|
|
82
90
|
return parts;
|
|
83
91
|
}
|
|
84
92
|
|
package/src/cli/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { initCommand } from "./commands/init";
|
|
|
6
6
|
import { serveCommand } from "./commands/serve";
|
|
7
7
|
import { devCommand } from "./commands/dev";
|
|
8
8
|
import { exposeCommand } from "./commands/expose";
|
|
9
|
+
import { openCommand } from "./commands/open";
|
|
9
10
|
import { listCommand } from "./commands/list";
|
|
10
11
|
import { stopCommand } from "./commands/stop";
|
|
11
12
|
import { updateCommand } from "./commands/update";
|
|
@@ -19,6 +20,7 @@ yargs(hideBin(process.argv))
|
|
|
19
20
|
.command(serveCommand)
|
|
20
21
|
.command(devCommand)
|
|
21
22
|
.command(exposeCommand)
|
|
23
|
+
.command(openCommand)
|
|
22
24
|
.command(listCommand)
|
|
23
25
|
.command(stopCommand)
|
|
24
26
|
.command(updateCommand)
|
package/src/cli/rtc-daemon.ts
CHANGED
|
@@ -69,27 +69,39 @@ function nativeLoadError(cause: unknown): Error {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
export interface RtcDaemonOptions {
|
|
72
|
-
/** Relay a signal to a
|
|
72
|
+
/** Relay a signal to a client peer via the signaling channel. */
|
|
73
73
|
sendSignal: (to: string, data: RtcSignal) => void;
|
|
74
|
-
/** Called when a
|
|
75
|
-
onChannel: (
|
|
74
|
+
/** Called when a client's data channel opens. */
|
|
75
|
+
onChannel: (clientId: string, channel: DataChannel) => void;
|
|
76
|
+
/**
|
|
77
|
+
* Admission gate. Resolve true to answer the client's offer, false to deny.
|
|
78
|
+
* Until it resolves we buffer the answer + local ICE so a pending client never
|
|
79
|
+
* completes a connection. Omitted → admit everyone (default).
|
|
80
|
+
*/
|
|
81
|
+
admit?: (clientId: string) => Promise<boolean>;
|
|
82
|
+
/** Called once when a client's connection is torn down (drop / deny / kick). */
|
|
83
|
+
onClose?: (clientId: string) => void;
|
|
76
84
|
}
|
|
77
85
|
|
|
78
|
-
interface
|
|
86
|
+
interface ClientConn {
|
|
79
87
|
pc: PeerConnectionT;
|
|
88
|
+
/** True once the host has admitted this client; gates outbound answer/ICE. */
|
|
89
|
+
admitted: boolean;
|
|
90
|
+
/** Outbound signals withheld while admission is pending. */
|
|
91
|
+
buffered: RtcSignal[];
|
|
80
92
|
}
|
|
81
93
|
|
|
82
94
|
/**
|
|
83
|
-
* Daemon-side WebRTC manager. The browser (
|
|
84
|
-
*
|
|
95
|
+
* Daemon-side WebRTC manager. The browser (client) is the offerer; for each
|
|
96
|
+
* client that sends an offer we create an answering PeerConnection and surface
|
|
85
97
|
* its data channel. STUN-only.
|
|
86
98
|
*/
|
|
87
99
|
export class RtcDaemon {
|
|
88
|
-
private
|
|
100
|
+
private clients = new Map<string, ClientConn>();
|
|
89
101
|
|
|
90
102
|
constructor(private opts: RtcDaemonOptions) {}
|
|
91
103
|
|
|
92
|
-
/** Route an inbound signaling payload from a
|
|
104
|
+
/** Route an inbound signaling payload from a client. */
|
|
93
105
|
handleSignal(from: string, data: unknown): void {
|
|
94
106
|
const sig = data as RtcSignal;
|
|
95
107
|
if (!sig || typeof sig !== "object") return;
|
|
@@ -97,7 +109,7 @@ export class RtcDaemon {
|
|
|
97
109
|
if (sig.kind === "offer") {
|
|
98
110
|
this.acceptOffer(from, sig.sdp);
|
|
99
111
|
} else if (sig.kind === "candidate") {
|
|
100
|
-
const conn = this.
|
|
112
|
+
const conn = this.clients.get(from);
|
|
101
113
|
if (conn) {
|
|
102
114
|
try {
|
|
103
115
|
conn.pc.addRemoteCandidate(sig.candidate, sig.mid);
|
|
@@ -108,60 +120,88 @@ export class RtcDaemon {
|
|
|
108
120
|
}
|
|
109
121
|
}
|
|
110
122
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
this.
|
|
123
|
+
/** Close a specific client's connection (host kick). */
|
|
124
|
+
close(clientId: string): void {
|
|
125
|
+
this.dropClient(clientId);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private acceptOffer(clientId: string, sdp: string): void {
|
|
129
|
+
// Replace any prior connection for this client (e.g. page reload).
|
|
130
|
+
this.dropClient(clientId);
|
|
114
131
|
|
|
115
|
-
const pc = new ndc.PeerConnection(`
|
|
132
|
+
const pc = new ndc.PeerConnection(`client-${clientId.slice(0, 8)}`, {
|
|
116
133
|
iceServers: ICE_SERVERS,
|
|
117
134
|
});
|
|
118
|
-
|
|
135
|
+
const conn: ClientConn = { pc, admitted: false, buffered: [] };
|
|
136
|
+
this.clients.set(clientId, conn);
|
|
137
|
+
|
|
138
|
+
// Withhold the answer + our ICE until the host admits this client; we still
|
|
139
|
+
// accept the offer + remote candidates so ICE is ready to flush instantly.
|
|
140
|
+
const emit = (sig: RtcSignal) => {
|
|
141
|
+
if (conn.admitted) this.opts.sendSignal(clientId, sig);
|
|
142
|
+
else conn.buffered.push(sig);
|
|
143
|
+
};
|
|
119
144
|
|
|
120
145
|
pc.onLocalDescription((localSdp, type) => {
|
|
121
|
-
|
|
122
|
-
kind: type as "answer",
|
|
123
|
-
type: type as "answer",
|
|
124
|
-
sdp: localSdp,
|
|
125
|
-
});
|
|
146
|
+
emit({ kind: type as "answer", type: type as "answer", sdp: localSdp });
|
|
126
147
|
});
|
|
127
148
|
|
|
128
149
|
pc.onLocalCandidate((candidate, mid) => {
|
|
129
|
-
|
|
150
|
+
emit({ kind: "candidate", candidate, mid });
|
|
130
151
|
});
|
|
131
152
|
|
|
132
153
|
pc.onStateChange((state) => {
|
|
133
|
-
console.log(`[rtc] ${
|
|
154
|
+
console.log(`[rtc] ${clientId.slice(0, 8)} state: ${state}`);
|
|
134
155
|
if (state === "disconnected" || state === "failed" || state === "closed") {
|
|
135
|
-
this.
|
|
156
|
+
this.dropClient(clientId);
|
|
136
157
|
}
|
|
137
158
|
});
|
|
138
159
|
|
|
139
160
|
pc.onDataChannel((dc) => {
|
|
140
|
-
console.log(`[rtc] ${
|
|
141
|
-
this.opts.onChannel(
|
|
161
|
+
console.log(`[rtc] ${clientId.slice(0, 8)} channel "${dc.getLabel()}" open`);
|
|
162
|
+
this.opts.onChannel(clientId, dc);
|
|
142
163
|
});
|
|
143
164
|
|
|
144
165
|
try {
|
|
145
166
|
pc.setRemoteDescription(sdp, "offer");
|
|
146
167
|
} catch (err) {
|
|
147
|
-
console.error(`[rtc] setRemoteDescription failed for ${
|
|
148
|
-
this.
|
|
168
|
+
console.error(`[rtc] setRemoteDescription failed for ${clientId.slice(0, 8)}:`, err);
|
|
169
|
+
this.dropClient(clientId);
|
|
170
|
+
return;
|
|
149
171
|
}
|
|
172
|
+
|
|
173
|
+
const admit = this.opts.admit ? this.opts.admit(clientId) : Promise.resolve(true);
|
|
174
|
+
admit
|
|
175
|
+
.then((ok) => {
|
|
176
|
+
// The client may have reloaded/dropped while we waited — only act if
|
|
177
|
+
// this exact connection is still current.
|
|
178
|
+
if (this.clients.get(clientId) !== conn) return;
|
|
179
|
+
if (!ok) {
|
|
180
|
+
this.opts.sendSignal(clientId, { kind: "denied" });
|
|
181
|
+
this.dropClient(clientId);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
conn.admitted = true;
|
|
185
|
+
for (const sig of conn.buffered) this.opts.sendSignal(clientId, sig);
|
|
186
|
+
conn.buffered = [];
|
|
187
|
+
})
|
|
188
|
+
.catch(() => this.dropClient(clientId));
|
|
150
189
|
}
|
|
151
190
|
|
|
152
|
-
private
|
|
153
|
-
const conn = this.
|
|
191
|
+
private dropClient(clientId: string): void {
|
|
192
|
+
const conn = this.clients.get(clientId);
|
|
154
193
|
if (!conn) return;
|
|
155
|
-
this.
|
|
194
|
+
this.clients.delete(clientId);
|
|
156
195
|
try {
|
|
157
196
|
conn.pc.close();
|
|
158
197
|
} catch {
|
|
159
198
|
// ignore
|
|
160
199
|
}
|
|
200
|
+
this.opts.onClose?.(clientId);
|
|
161
201
|
}
|
|
162
202
|
|
|
163
203
|
closeAll(): void {
|
|
164
|
-
for (const id of [...this.
|
|
204
|
+
for (const id of [...this.clients.keys()]) this.dropClient(id);
|
|
165
205
|
try {
|
|
166
206
|
ndc.cleanup();
|
|
167
207
|
} catch {
|
package/src/cli/run-server.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { watch } from "node:fs";
|
|
2
2
|
import { basename, dirname } from "node:path";
|
|
3
|
-
import { type PeerMeta, newPeerId } from "../shared/signaling";
|
|
3
|
+
import { type PeerMeta, isClientRole, newPeerId } from "../shared/signaling";
|
|
4
4
|
import { SignalingClient } from "../shared/signaling-client";
|
|
5
|
+
import { Approver, type ApprovePolicy } from "./approver";
|
|
5
6
|
import { RtcDaemon } from "./rtc-daemon";
|
|
6
7
|
import { type LocalRequest, Tunnel } from "./tunnel";
|
|
7
8
|
import { handleProvision, isProvisionPath, type ProvisionDeps } from "./provision-server";
|
|
@@ -46,6 +47,10 @@ export interface RunServerOptions {
|
|
|
46
47
|
* host workspace registry). Watched via their parent directory so the file
|
|
47
48
|
* may not exist yet. */
|
|
48
49
|
watchFiles?: string[];
|
|
50
|
+
/** Client admission policy (default "auto"). */
|
|
51
|
+
approve?: ApprovePolicy;
|
|
52
|
+
/** Label substrings auto-approved under the "confirm" policy. */
|
|
53
|
+
allow?: string[];
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
/** How often a daemon re-enumerates its workspaces (manual clones show up). */
|
|
@@ -53,7 +58,7 @@ const META_REFRESH_MS = 60_000;
|
|
|
53
58
|
|
|
54
59
|
/**
|
|
55
60
|
* Foreground server loop shared by `serve`, `dev`, and `expose`: register in the
|
|
56
|
-
* signaling room with the given meta and bridge each
|
|
61
|
+
* signaling room with the given meta and bridge each client's data channel to a
|
|
57
62
|
* local server (VS Code for serve/dev, an arbitrary port for expose). Never
|
|
58
63
|
* resolves.
|
|
59
64
|
*/
|
|
@@ -68,6 +73,23 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
|
|
|
68
73
|
const target = await opts.launch(basePath);
|
|
69
74
|
|
|
70
75
|
let rtc: RtcDaemon;
|
|
76
|
+
|
|
77
|
+
// Track client labels from the room roster so approval prompts and the bridge
|
|
78
|
+
// log name *who* connected (a leaked-token tell), not just an opaque peerId.
|
|
79
|
+
const clientNames = new Map<string, string>();
|
|
80
|
+
const labelFor = (clientId: string) => clientNames.get(clientId) ?? "unknown client";
|
|
81
|
+
|
|
82
|
+
const approver = new Approver({
|
|
83
|
+
policy: opts.approve ?? "auto",
|
|
84
|
+
allow: opts.allow ?? [],
|
|
85
|
+
kick: (clientId) => {
|
|
86
|
+
// Tell the client why it's being cut off, then tear down the connection.
|
|
87
|
+
client.sendSignal(clientId, { kind: "denied" });
|
|
88
|
+
rtc.close(clientId);
|
|
89
|
+
},
|
|
90
|
+
notifyPending: (clientId) => client.sendSignal(clientId, { kind: "pending" }),
|
|
91
|
+
});
|
|
92
|
+
|
|
71
93
|
const client = new SignalingClient({
|
|
72
94
|
url: opts.signal,
|
|
73
95
|
token: opts.token,
|
|
@@ -82,6 +104,12 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
|
|
|
82
104
|
const detail = info ? ` (code ${info.code}${info.reason ? ` "${info.reason}"` : ""}, up ${info.ms}ms)` : "";
|
|
83
105
|
console.log(`[codehost] disconnected from signaling${detail}, reconnecting…`);
|
|
84
106
|
},
|
|
107
|
+
onPeers: (peers) => {
|
|
108
|
+
clientNames.clear();
|
|
109
|
+
for (const p of peers) {
|
|
110
|
+
if (isClientRole(p.role) && p.meta?.name) clientNames.set(p.peerId, p.meta.name);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
85
113
|
onSignal: (from, data) => rtc.handleSignal(from, data),
|
|
86
114
|
});
|
|
87
115
|
|
|
@@ -107,14 +135,30 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
|
|
|
107
135
|
}
|
|
108
136
|
: undefined;
|
|
109
137
|
|
|
138
|
+
// A client opens two data channels (interactive + bulk), so onChannel fires
|
|
139
|
+
// twice per connection — log/register "connected" only on the first.
|
|
140
|
+
const bridged = new Set<string>();
|
|
110
141
|
rtc = new RtcDaemon({
|
|
111
142
|
sendSignal: (to, data) => client.sendSignal(to, data),
|
|
112
|
-
|
|
113
|
-
|
|
143
|
+
admit: (clientId) => approver.admit(clientId, labelFor(clientId)),
|
|
144
|
+
onChannel: (clientId, channel) => {
|
|
145
|
+
if (!bridged.has(clientId)) {
|
|
146
|
+
bridged.add(clientId);
|
|
147
|
+
const who = labelFor(clientId);
|
|
148
|
+
console.log(`[codehost] ${who} (${clientId.slice(0, 8)}) connected; bridging to :${target.port}`);
|
|
149
|
+
approver.onConnected(clientId, who);
|
|
150
|
+
}
|
|
114
151
|
new Tunnel(channel, target.port, target.stripBasePath, onLocal);
|
|
115
152
|
},
|
|
153
|
+
onClose: (clientId) => {
|
|
154
|
+
bridged.delete(clientId);
|
|
155
|
+
approver.onDisconnected(clientId);
|
|
156
|
+
},
|
|
116
157
|
});
|
|
117
158
|
|
|
159
|
+
approver.banner();
|
|
160
|
+
approver.start();
|
|
161
|
+
|
|
118
162
|
client.connect();
|
|
119
163
|
if (opts.refreshMeta) {
|
|
120
164
|
setInterval(refreshMeta, opts.metaRefreshMs ?? META_REFRESH_MS);
|
|
@@ -144,6 +188,7 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
|
|
|
144
188
|
|
|
145
189
|
const shutdown = () => {
|
|
146
190
|
console.log("\n[codehost] shutting down");
|
|
191
|
+
approver.stop();
|
|
147
192
|
rtc.closeAll();
|
|
148
193
|
client.close();
|
|
149
194
|
target.stop?.();
|
package/src/shared/repo.ts
CHANGED
|
@@ -219,7 +219,7 @@ export function resolveRepoTarget(
|
|
|
219
219
|
.sort(
|
|
220
220
|
(a, b) =>
|
|
221
221
|
Number(prefers(b.meta, prefer)) - Number(prefers(a.meta, prefer)) ||
|
|
222
|
-
(b.meta?.cwd
|
|
222
|
+
(b.meta?.cwd?.length ?? 0) - (a.meta?.cwd?.length ?? 0),
|
|
223
223
|
);
|
|
224
224
|
|
|
225
225
|
// A root that *enumerated* a matching checkout knows it exists on disk —
|
|
@@ -233,7 +233,7 @@ export function resolveRepoTarget(
|
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
const root = roots[0];
|
|
236
|
-
if (root && root.meta) {
|
|
236
|
+
if (root && root.meta?.cwd) {
|
|
237
237
|
const folder = `${trimSlash(root.meta.cwd)}/${fillLayout(root.meta.layout || DEFAULT_LAYOUT, target)}`;
|
|
238
238
|
return { peerId: root.peerId, folder };
|
|
239
239
|
}
|
|
@@ -250,7 +250,7 @@ export function resolveRepoTarget(
|
|
|
250
250
|
export function resolveDevTarget(servers: PeerInfo[], target: DevTarget): Resolution | null {
|
|
251
251
|
const want = stripEnds(target.path);
|
|
252
252
|
const hostOk = (meta: PeerMeta) => !target.host || meta.host === target.host;
|
|
253
|
-
const hit = servers.find((s) => s.meta && stripEnds(s.meta.cwd) === want && hostOk(s.meta));
|
|
253
|
+
const hit = servers.find((s) => s.meta?.cwd && stripEnds(s.meta.cwd) === want && hostOk(s.meta));
|
|
254
254
|
if (hit) return { peerId: hit.peerId };
|
|
255
255
|
for (const s of servers) {
|
|
256
256
|
if (!s.meta || !hostOk(s.meta)) continue;
|
package/src/shared/rtc.ts
CHANGED
|
@@ -20,7 +20,19 @@ export interface CandidateSignal {
|
|
|
20
20
|
mid: string;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Host-side admission control, relayed opaquely like any other signal. The
|
|
25
|
+
* daemon sends "pending" while a client awaits the host's approval and "denied"
|
|
26
|
+
* if the host rejects (or kicks) it — so the client can show why it's waiting or
|
|
27
|
+
* was cut off, instead of a silent hang. Approval needs no signal: the daemon
|
|
28
|
+
* just answers the offer. Old daemons never send these; old clients ignore an
|
|
29
|
+
* unknown `kind`, so this is backward compatible both ways.
|
|
30
|
+
*/
|
|
31
|
+
export interface ControlSignal {
|
|
32
|
+
kind: "pending" | "denied";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type RtcSignal = SdpSignal | CandidateSignal | ControlSignal;
|
|
24
36
|
|
|
25
37
|
/** Label used for the control/tunnel data channel. */
|
|
26
38
|
export const CHANNEL_LABEL = "codehost";
|
|
@@ -14,7 +14,7 @@ export interface SignalingClientOptions {
|
|
|
14
14
|
role: Role;
|
|
15
15
|
meta?: PeerMeta;
|
|
16
16
|
peerId?: string;
|
|
17
|
-
onPeers?: (peers: PeerInfo[]) => void;
|
|
17
|
+
onPeers?: (peers: PeerInfo[], now?: number) => void;
|
|
18
18
|
onSignal?: (from: string, data: unknown) => void;
|
|
19
19
|
onOpen?: () => void;
|
|
20
20
|
/** Called on every socket close. `info` carries the WebSocket close code,
|
|
@@ -192,7 +192,7 @@ export class SignalingClient {
|
|
|
192
192
|
} catch {
|
|
193
193
|
return;
|
|
194
194
|
}
|
|
195
|
-
if (msg.type === "peers") this.opts.onPeers?.(msg.peers);
|
|
195
|
+
if (msg.type === "peers") this.opts.onPeers?.(msg.peers, msg.now);
|
|
196
196
|
else if (msg.type === "signal") this.opts.onSignal?.(msg.from, msg.data);
|
|
197
197
|
};
|
|
198
198
|
|
package/src/shared/signaling.ts
CHANGED
|
@@ -2,7 +2,26 @@
|
|
|
2
2
|
// Worker / Durable Object. A "room" is keyed by the user's token; every member
|
|
3
3
|
// of a room can see the others and exchange WebRTC SDP/ICE via the relay.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Room roles. A connecting browser is a "client"; "viewer" is the legacy wire
|
|
7
|
+
* value for the same role, kept so older daemons/pages still interop (the term
|
|
8
|
+
* understated the access — a connected client gets the host's full VS Code:
|
|
9
|
+
* terminal + file write). Backward-compat plan ("accept both, emit old"):
|
|
10
|
+
* receivers treat client and viewer alike via `isClientRole`, and new code
|
|
11
|
+
* still EMITS the legacy `CLIENT_WIRE_ROLE` ("viewer") until daemons have rolled
|
|
12
|
+
* forward; a later release flips the emit to "client".
|
|
13
|
+
*/
|
|
14
|
+
export type Role = "server" | "client" | "viewer";
|
|
15
|
+
|
|
16
|
+
/** The connecting-role value new code emits. Still the legacy "viewer" during
|
|
17
|
+
* the accept-both transition; flip to "client" once daemons recognize it. */
|
|
18
|
+
export const CLIENT_WIRE_ROLE: Role = "viewer";
|
|
19
|
+
|
|
20
|
+
/** True for either spelling of the connecting (browser) role. Use everywhere a
|
|
21
|
+
* receiver decides "is this peer a client" so both old and new peers match. */
|
|
22
|
+
export function isClientRole(role: Role): boolean {
|
|
23
|
+
return role === "client" || role === "viewer";
|
|
24
|
+
}
|
|
6
25
|
|
|
7
26
|
/** One live agent CLI session on the daemon's machine (sourced from agent-yes's
|
|
8
27
|
* registry) — advertised so clients can see which agents run where. Interact
|
|
@@ -36,14 +55,19 @@ export interface WorkspaceInfo {
|
|
|
36
55
|
config?: boolean;
|
|
37
56
|
}
|
|
38
57
|
|
|
39
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Metadata a room member advertises. Servers (`codehost serve`/`dev`) fill the
|
|
60
|
+
* workspace fields; a client (the codehost.dev page) sends just `name` as its
|
|
61
|
+
* roster label and leaves the server-only fields unset — hence everything but
|
|
62
|
+
* `name` is optional.
|
|
63
|
+
*/
|
|
40
64
|
export interface PeerMeta {
|
|
41
|
-
/** Human label
|
|
65
|
+
/** Human label. Server: defaults to hostname. Client: a browser/OS label. */
|
|
42
66
|
name: string;
|
|
43
|
-
/**
|
|
44
|
-
cwd
|
|
45
|
-
/**
|
|
46
|
-
host
|
|
67
|
+
/** Server only: directory the VS Code instance is serving (repo dir or root). */
|
|
68
|
+
cwd?: string;
|
|
69
|
+
/** Server only: hostname of the machine running the daemon. */
|
|
70
|
+
host?: string;
|
|
47
71
|
/**
|
|
48
72
|
* Stable machine identity (UUID persisted in ~/.codehost/config.json). All
|
|
49
73
|
* daemons on one machine share it, unlike the per-process peerId, so clients
|
|
@@ -84,6 +108,10 @@ export interface PeerInfo {
|
|
|
84
108
|
peerId: string;
|
|
85
109
|
role: Role;
|
|
86
110
|
meta: PeerMeta | null;
|
|
111
|
+
/** Worker-stamped join time (ms). Compare against `PeersMessage.now`, which
|
|
112
|
+
* is on the same clock, for a roster "connected N ago" without clock skew.
|
|
113
|
+
* Absent from older workers. */
|
|
114
|
+
since?: number;
|
|
87
115
|
}
|
|
88
116
|
|
|
89
117
|
// ---- Client -> Server ----
|
|
@@ -133,6 +161,10 @@ export interface WelcomeMessage {
|
|
|
133
161
|
export interface PeersMessage {
|
|
134
162
|
type: "peers";
|
|
135
163
|
peers: PeerInfo[];
|
|
164
|
+
/** Worker wall-clock (ms) at send time — same clock as `PeerInfo.since`, so a
|
|
165
|
+
* client renders relative join ages without trusting its own clock. Absent
|
|
166
|
+
* from older workers. */
|
|
167
|
+
now?: number;
|
|
136
168
|
}
|
|
137
169
|
|
|
138
170
|
/** A signal relayed from another peer. */
|
package/src/web/discovery.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
import type
|
|
2
|
+
import { type AgentInfo, type PeerInfo, type WorkspaceInfo, CLIENT_WIRE_ROLE, isClientRole } from "../shared/signaling";
|
|
3
3
|
import { TOKEN_REQUIREMENTS, validateToken } from "../shared/token";
|
|
4
4
|
import { SignalingClient } from "../shared/signaling-client";
|
|
5
5
|
import type { RtcSignal } from "../shared/rtc";
|
|
@@ -26,11 +26,43 @@ import { deriveTags, matchQuery, shortRoomLabel, tagKey } from "../shared/tags";
|
|
|
26
26
|
|
|
27
27
|
const TOKEN_KEY = "codehost.token";
|
|
28
28
|
|
|
29
|
-
type ConnState = "idle" | "connecting" | "provisioning" | "connected" | "failed";
|
|
29
|
+
type ConnState = "idle" | "connecting" | "pending" | "provisioning" | "connected" | "failed" | "denied";
|
|
30
30
|
|
|
31
31
|
/** A server discovered in a specific room (its token routes the signaling). */
|
|
32
32
|
type RoomedServer = { server: PeerInfo; room: string };
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* A short "Browser · OS" label this page advertises in the room roster, so the
|
|
36
|
+
* host and other clients can tell devices apart (and spot a stranger that
|
|
37
|
+
* shouldn't have the token). Best-effort UA sniff; falls back to "browser".
|
|
38
|
+
*/
|
|
39
|
+
function clientLabel(): string {
|
|
40
|
+
const ua = navigator.userAgent;
|
|
41
|
+
const browser = /Edg\//.test(ua) ? "Edge"
|
|
42
|
+
: /OPR\//.test(ua) ? "Opera"
|
|
43
|
+
: /Firefox\//.test(ua) ? "Firefox"
|
|
44
|
+
: /Chrome\//.test(ua) ? "Chrome"
|
|
45
|
+
: /Safari\//.test(ua) ? "Safari"
|
|
46
|
+
: "browser";
|
|
47
|
+
const os = /Mac OS X/.test(ua) ? "macOS"
|
|
48
|
+
: /Windows/.test(ua) ? "Windows"
|
|
49
|
+
: /Android/.test(ua) ? "Android"
|
|
50
|
+
: /(iPhone|iPad|iPod)/.test(ua) ? "iOS"
|
|
51
|
+
: /Linux/.test(ua) ? "Linux"
|
|
52
|
+
: "";
|
|
53
|
+
return os ? `${browser} · ${os}` : browser;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Coarse "Ns/Nm/Nh" from a worker-clock join time and the room's clock. */
|
|
57
|
+
function relTime(since?: number, now?: number): string | null {
|
|
58
|
+
if (!since || !now) return null;
|
|
59
|
+
const secs = Math.max(0, Math.round((now - since) / 1000));
|
|
60
|
+
if (secs < 60) return `${secs}s`;
|
|
61
|
+
const mins = Math.round(secs / 60);
|
|
62
|
+
if (mins < 60) return `${mins}m`;
|
|
63
|
+
return `${Math.round(mins / 60)}h`;
|
|
64
|
+
}
|
|
65
|
+
|
|
34
66
|
/**
|
|
35
67
|
* Read a room token handed in the URL fragment as `#t=<token>` (what the CLI
|
|
36
68
|
* prints/opens after `setup`/`serve`). The page is static, so the fragment
|
|
@@ -98,7 +130,7 @@ function findRoomForDeepLink(dl: DeepLink, tokens: string[], timeoutMs = 6000):
|
|
|
98
130
|
const client = new SignalingClient({
|
|
99
131
|
url: getSignalUrl(),
|
|
100
132
|
token: tok,
|
|
101
|
-
role:
|
|
133
|
+
role: CLIENT_WIRE_ROLE,
|
|
102
134
|
onPeers: (peers) => {
|
|
103
135
|
const servers = peers.filter((p) => p.role === "server");
|
|
104
136
|
const res =
|
|
@@ -124,7 +156,9 @@ function findRoomForDeepLink(dl: DeepLink, tokens: string[], timeoutMs = 6000):
|
|
|
124
156
|
*/
|
|
125
157
|
function RoomClient(props: {
|
|
126
158
|
token: string;
|
|
159
|
+
label: string;
|
|
127
160
|
onPeers: (peers: PeerInfo[]) => void;
|
|
161
|
+
onRoster: (clients: PeerInfo[], now?: number) => void;
|
|
128
162
|
onStatus: (open: boolean) => void;
|
|
129
163
|
onSignal: (from: string, data: unknown) => void;
|
|
130
164
|
registerSender: (send: ((to: string, data: unknown) => void) | null) => void;
|
|
@@ -133,15 +167,23 @@ function RoomClient(props: {
|
|
|
133
167
|
// not on every parent re-render (which would needlessly churn the WebSocket).
|
|
134
168
|
const cb = useRef(props);
|
|
135
169
|
cb.current = props;
|
|
136
|
-
const { token } = props;
|
|
170
|
+
const { token, label } = props;
|
|
137
171
|
useEffect(() => {
|
|
138
172
|
const client = new SignalingClient({
|
|
139
173
|
url: getSignalUrl(),
|
|
140
174
|
token,
|
|
141
|
-
role
|
|
175
|
+
// Connecting role. CLIENT_WIRE_ROLE is still the legacy "viewer" during the
|
|
176
|
+
// accept-both transition; servers match it via isClientRole either way.
|
|
177
|
+
role: CLIENT_WIRE_ROLE,
|
|
178
|
+
// Advertise a label so this tab shows up named in the room roster.
|
|
179
|
+
meta: { name: label },
|
|
142
180
|
onOpen: () => cb.current.onStatus(true),
|
|
143
181
|
onClose: () => cb.current.onStatus(false),
|
|
144
|
-
onPeers: (peers
|
|
182
|
+
onPeers: (peers, now) => {
|
|
183
|
+
cb.current.onPeers(peers.filter((p) => p.role === "server"));
|
|
184
|
+
// Other clients in the room (not us) — surfaced as the roster.
|
|
185
|
+
cb.current.onRoster(peers.filter((p) => isClientRole(p.role) && p.peerId !== client.peerId), now);
|
|
186
|
+
},
|
|
145
187
|
onSignal: (from, data) => cb.current.onSignal(from, data),
|
|
146
188
|
});
|
|
147
189
|
cb.current.registerSender((to, data) => client.sendSignal(to, data));
|
|
@@ -170,6 +212,12 @@ export function Discovery() {
|
|
|
170
212
|
// Per-room discovery state, merged into one workspace list below.
|
|
171
213
|
const [serversByRoom, setServersByRoom] = useState<Record<string, PeerInfo[]>>({});
|
|
172
214
|
const [roomOpen, setRoomOpen] = useState<Record<string, boolean>>({});
|
|
215
|
+
// Other clients (browsers) per room, for the "In this room" roster, plus the
|
|
216
|
+
// worker clock from the latest peers message for relative join times.
|
|
217
|
+
const [clientsByRoom, setClientsByRoom] = useState<Record<string, PeerInfo[]>>({});
|
|
218
|
+
const [roomNow, setRoomNow] = useState<number | undefined>(undefined);
|
|
219
|
+
// This tab's roster label, computed once.
|
|
220
|
+
const labelRef = useRef(clientLabel());
|
|
173
221
|
|
|
174
222
|
// Token input = "join another room": validated, then appended to the set.
|
|
175
223
|
// Never pre-filled with a saved token — it's a bearer secret.
|
|
@@ -204,6 +252,12 @@ export function Discovery() {
|
|
|
204
252
|
const activePeerRef = useRef<string | null>(null);
|
|
205
253
|
const activeRoomRef = useRef<string | null>(null);
|
|
206
254
|
const sendersRef = useRef<Map<string, (to: string, data: unknown) => void>>(new Map());
|
|
255
|
+
// Admission control: the host can hold ("pending") or reject ("denied") us.
|
|
256
|
+
// `deniedRef` stops a trailing pc state change from overwriting the denied UI;
|
|
257
|
+
// the timer/reject refs let a control signal extend or abort the dial attempt.
|
|
258
|
+
const deniedRef = useRef(false);
|
|
259
|
+
const dialTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
260
|
+
const dialRejectRef = useRef<((e: Error) => void) | null>(null);
|
|
207
261
|
// Whether the live connection pushed a history entry (so Disconnect/Back can
|
|
208
262
|
// pop it back to the list).
|
|
209
263
|
const pushedRef = useRef(false);
|
|
@@ -387,6 +441,7 @@ export function Discovery() {
|
|
|
387
441
|
const send = sendersRef.current.get(room);
|
|
388
442
|
if (!send) return;
|
|
389
443
|
dialingRef.current = true; // synchronous gate against concurrent triggers
|
|
444
|
+
deniedRef.current = false;
|
|
390
445
|
let didPush = false;
|
|
391
446
|
try {
|
|
392
447
|
// Clear any prior connection's broker state first: after an RTC drop the
|
|
@@ -435,23 +490,25 @@ export function Discovery() {
|
|
|
435
490
|
const rtc = new RtcClient({
|
|
436
491
|
sendSignal: (data: RtcSignal) => send(server.peerId, data),
|
|
437
492
|
onState: (state) => {
|
|
438
|
-
if (state === "failed" || state === "disconnected") setConnState("failed");
|
|
493
|
+
if ((state === "failed" || state === "disconnected") && !deniedRef.current) setConnState("failed");
|
|
439
494
|
},
|
|
440
495
|
onOpen: (channel) => {
|
|
441
|
-
clearTimeout(
|
|
496
|
+
if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
|
|
442
497
|
resolve({ channel, bulk: rtc.bulkChannel });
|
|
443
498
|
},
|
|
444
499
|
onClose: () => setConnState((s) => (s === "connected" ? "idle" : s)),
|
|
445
500
|
});
|
|
446
501
|
rtcRef.current = rtc;
|
|
502
|
+
dialRejectRef.current = reject;
|
|
447
503
|
// Don't hang forever dialing a peer that never answers (e.g. a stale
|
|
448
|
-
// server still listed in the room): fail the attempt after 15s.
|
|
449
|
-
|
|
504
|
+
// server still listed in the room): fail the attempt after 15s. A
|
|
505
|
+
// "pending" admission signal swaps this for a longer approval window.
|
|
506
|
+
dialTimerRef.current = setTimeout(() => {
|
|
450
507
|
rtc.close();
|
|
451
508
|
reject(new Error("connection timed out"));
|
|
452
509
|
}, 15000);
|
|
453
510
|
rtc.start().catch((err) => {
|
|
454
|
-
clearTimeout(
|
|
511
|
+
if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
|
|
455
512
|
reject(err);
|
|
456
513
|
});
|
|
457
514
|
});
|
|
@@ -495,7 +552,7 @@ export function Discovery() {
|
|
|
495
552
|
setResolving(null);
|
|
496
553
|
recordConnect(server, room, openFolder);
|
|
497
554
|
} catch {
|
|
498
|
-
setConnState("failed");
|
|
555
|
+
setConnState(deniedRef.current ? "denied" : "failed");
|
|
499
556
|
// Undo the optimistic history entry we pushed. revertingRef makes the
|
|
500
557
|
// resulting popstate a no-op so the "failed" card stays on the list.
|
|
501
558
|
if (didPush) {
|
|
@@ -717,6 +774,12 @@ export function Discovery() {
|
|
|
717
774
|
const onlineRooms = tokens.filter((t) => roomOpen[t]).length;
|
|
718
775
|
const activeServer = allServers.find((x) => x.server.peerId === activePeerId)?.server;
|
|
719
776
|
|
|
777
|
+
// Other clients (browsers) across all joined rooms, deduped by peerId — the
|
|
778
|
+
// "In this room" roster, so you can spot a device that shouldn't hold a token.
|
|
779
|
+
const otherClients = Object.values(clientsByRoom)
|
|
780
|
+
.flat()
|
|
781
|
+
.filter((c, i, all) => all.findIndex((x) => x.peerId === c.peerId) === i);
|
|
782
|
+
|
|
720
783
|
// Annotate each server with its mnemonic fake-tags (incl. its room label), then
|
|
721
784
|
// filter. The room token is hashed to a short label — never rendered raw.
|
|
722
785
|
const tagged = allServers.map(({ server: s, room }) => ({
|
|
@@ -764,10 +827,39 @@ export function Discovery() {
|
|
|
764
827
|
<RoomClient
|
|
765
828
|
key={t}
|
|
766
829
|
token={t}
|
|
830
|
+
label={labelRef.current}
|
|
767
831
|
onPeers={(peers) => setServersByRoom((m) => ({ ...m, [t]: peers }))}
|
|
832
|
+
onRoster={(clients, now) => {
|
|
833
|
+
setClientsByRoom((m) => ({ ...m, [t]: clients }));
|
|
834
|
+
if (now) setRoomNow(now);
|
|
835
|
+
}}
|
|
768
836
|
onStatus={(open) => setRoomOpen((m) => ({ ...m, [t]: open }))}
|
|
769
837
|
onSignal={(from, data) => {
|
|
770
|
-
if (from
|
|
838
|
+
if (from !== activePeerRef.current) return;
|
|
839
|
+
const kind = (data as { kind?: string } | null)?.kind;
|
|
840
|
+
if (kind === "pending") {
|
|
841
|
+
// Host is reviewing us — show "waiting" and stop the short dial timer
|
|
842
|
+
// from failing the attempt while a human decides.
|
|
843
|
+
setConnState("pending");
|
|
844
|
+
if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
|
|
845
|
+
dialTimerRef.current = setTimeout(() => {
|
|
846
|
+
rtcRef.current?.close();
|
|
847
|
+
dialRejectRef.current?.(new Error("approval timed out"));
|
|
848
|
+
}, 120000);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if (kind === "denied") {
|
|
852
|
+
// Denied while dialing, or kicked after connecting — set state directly
|
|
853
|
+
// so it covers both (the dial promise may already be settled).
|
|
854
|
+
deniedRef.current = true;
|
|
855
|
+
if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
|
|
856
|
+
rtcRef.current?.close();
|
|
857
|
+
setIframeSrc(null);
|
|
858
|
+
setConnState("denied");
|
|
859
|
+
dialRejectRef.current?.(new Error("host denied the connection"));
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
void rtcRef.current?.handleSignal(data);
|
|
771
863
|
}}
|
|
772
864
|
registerSender={(send) => {
|
|
773
865
|
if (send) sendersRef.current.set(t, send);
|
|
@@ -1078,18 +1170,20 @@ export function Discovery() {
|
|
|
1078
1170
|
)}
|
|
1079
1171
|
<div style={styles.idLine}>peer {s.peerId.slice(0, 8)}</div>
|
|
1080
1172
|
{isActive && (
|
|
1081
|
-
<div style={styles.echo}>
|
|
1173
|
+
<div style={connState === "denied" ? styles.echoBad : styles.echo}>
|
|
1082
1174
|
{connState === "connecting" && "negotiating WebRTC…"}
|
|
1175
|
+
{connState === "pending" && "waiting for the host to approve you…"}
|
|
1083
1176
|
{connState === "failed" && "connection failed"}
|
|
1177
|
+
{connState === "denied" && "the host denied this connection"}
|
|
1084
1178
|
</div>
|
|
1085
1179
|
)}
|
|
1086
1180
|
</div>
|
|
1087
1181
|
<button
|
|
1088
1182
|
style={styles.connectBtn}
|
|
1089
1183
|
onClick={() => connectTo(s, room)}
|
|
1090
|
-
disabled={isActive && connState === "connecting"}
|
|
1184
|
+
disabled={isActive && (connState === "connecting" || connState === "pending")}
|
|
1091
1185
|
>
|
|
1092
|
-
{isActive && connState === "connecting" ? "…" : "Connect"}
|
|
1186
|
+
{isActive && (connState === "connecting" || connState === "pending") ? "…" : "Connect"}
|
|
1093
1187
|
</button>
|
|
1094
1188
|
</li>
|
|
1095
1189
|
);
|
|
@@ -1101,6 +1195,36 @@ export function Discovery() {
|
|
|
1101
1195
|
<p style={styles.dim}>No workspace matches your filter.</p>
|
|
1102
1196
|
)}
|
|
1103
1197
|
</div>
|
|
1198
|
+
|
|
1199
|
+
{tokens.length > 0 && (
|
|
1200
|
+
<section style={styles.rosterSection}>
|
|
1201
|
+
<div style={styles.rosterHead}>
|
|
1202
|
+
In this room · you{otherClients.length > 0 ? ` + ${otherClients.length} other ${otherClients.length === 1 ? "client" : "clients"}` : ""}
|
|
1203
|
+
</div>
|
|
1204
|
+
<ul style={styles.list}>
|
|
1205
|
+
<li style={styles.personRow}>
|
|
1206
|
+
<span style={styles.personDot}>●</span>
|
|
1207
|
+
<span style={styles.personName}>{labelRef.current}</span>
|
|
1208
|
+
<span style={styles.dim}>you</span>
|
|
1209
|
+
</li>
|
|
1210
|
+
{otherClients.map((c) => {
|
|
1211
|
+
const age = relTime(c.since, roomNow);
|
|
1212
|
+
return (
|
|
1213
|
+
<li key={c.peerId} style={styles.personRow}>
|
|
1214
|
+
<span style={{ ...styles.personDot, color: "#dcb67a" }}>●</span>
|
|
1215
|
+
<span style={styles.personName}>{c.meta?.name ?? c.peerId.slice(0, 8)}</span>
|
|
1216
|
+
{age && <span style={styles.dim}>connected {age} ago</span>}
|
|
1217
|
+
</li>
|
|
1218
|
+
);
|
|
1219
|
+
})}
|
|
1220
|
+
</ul>
|
|
1221
|
+
<p style={styles.rosterHint}>
|
|
1222
|
+
Anyone holding a room's token can appear here and gets a full VS Code session
|
|
1223
|
+
(terminal + file write). See a device you don't recognize? Rotate the token with{" "}
|
|
1224
|
+
<code style={styles.code}>codehost setup --new-token</code>.
|
|
1225
|
+
</p>
|
|
1226
|
+
</section>
|
|
1227
|
+
)}
|
|
1104
1228
|
</main>
|
|
1105
1229
|
</div>
|
|
1106
1230
|
</>
|
|
@@ -1185,6 +1309,13 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
1185
1309
|
cardSub: { display: "flex", gap: 12, fontSize: 12, color: "#888", marginTop: 2 },
|
|
1186
1310
|
cwd: { fontFamily: "monospace" },
|
|
1187
1311
|
echo: { marginTop: 6, fontSize: 12, color: "#4ec9b0", fontFamily: "monospace" },
|
|
1312
|
+
echoBad: { marginTop: 6, fontSize: 12, color: "#f48771", fontFamily: "monospace" },
|
|
1313
|
+
rosterSection: { marginTop: 28 },
|
|
1314
|
+
rosterHead: { fontSize: 14, color: "#aaa", fontWeight: 600, margin: "0 0 12px" },
|
|
1315
|
+
rosterHint: { margin: "10px 0 0", fontSize: 12, color: "#888" },
|
|
1316
|
+
personRow: { display: "flex", alignItems: "center", gap: 10, background: "#252525", border: "1px solid #3d3d3d", borderRadius: 8, padding: "8px 14px", fontSize: 13 },
|
|
1317
|
+
personDot: { color: "#4ec9b0", fontSize: 10 },
|
|
1318
|
+
personName: { color: "#eee", flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
|
1188
1319
|
connectBtn: { background: "#0e639c", border: "none", color: "#fff", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
1189
1320
|
shareBtn: { background: "transparent", border: "1px solid #3d3d3d", color: "#ccc", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
1190
1321
|
provLog: { flex: 1, margin: 0, padding: "14px 18px", overflow: "auto", background: "#1e1e1e", color: "#ccc", fontFamily: "monospace", fontSize: 12.5, lineHeight: 1.5, whiteSpace: "pre-wrap" },
|
package/src/web/room-client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SignalingClient } from "../shared/signaling-client";
|
|
2
|
-
import type
|
|
2
|
+
import { type PeerInfo, CLIENT_WIRE_ROLE } from "../shared/signaling";
|
|
3
3
|
import type { RtcSignal } from "../shared/rtc";
|
|
4
4
|
import { RtcClient } from "./rtc-client";
|
|
5
5
|
import { TunnelClient } from "./tunnel-client";
|
|
@@ -44,7 +44,7 @@ export class CodehostRoom {
|
|
|
44
44
|
this.signaling = new SignalingClient({
|
|
45
45
|
url: opts.signalUrl ?? DEFAULT_SIGNAL_URL,
|
|
46
46
|
token: opts.token,
|
|
47
|
-
role:
|
|
47
|
+
role: CLIENT_WIRE_ROLE,
|
|
48
48
|
onOpen: () => opts.onStatus?.(true),
|
|
49
49
|
onClose: () => opts.onStatus?.(false),
|
|
50
50
|
onPeers: (peers) => {
|
package/worker/room.ts
CHANGED
|
@@ -10,6 +10,8 @@ interface Attachment {
|
|
|
10
10
|
peerId: string;
|
|
11
11
|
role: Role;
|
|
12
12
|
meta: PeerMeta | null;
|
|
13
|
+
/** Wall-clock ms when this socket joined (sent `hello`); for the room roster. */
|
|
14
|
+
since: number;
|
|
13
15
|
/** Wall-clock ms of the last message from this socket (hello / ping / signal). */
|
|
14
16
|
lastSeen: number;
|
|
15
17
|
}
|
|
@@ -57,11 +59,13 @@ export class Room implements DurableObject {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
if (msg.type === "hello") {
|
|
62
|
+
const now = Date.now();
|
|
60
63
|
const att: Attachment = {
|
|
61
64
|
peerId: msg.peerId,
|
|
62
65
|
role: msg.role,
|
|
63
66
|
meta: msg.meta ?? null,
|
|
64
|
-
|
|
67
|
+
since: now,
|
|
68
|
+
lastSeen: now,
|
|
65
69
|
};
|
|
66
70
|
ws.serializeAttachment(att);
|
|
67
71
|
this.send(ws, { type: "welcome", peerId: msg.peerId });
|
|
@@ -165,13 +169,13 @@ export class Room implements DurableObject {
|
|
|
165
169
|
const peers: PeerInfo[] = [];
|
|
166
170
|
for (const ws of this.state.getWebSockets()) {
|
|
167
171
|
const att = this.attachment(ws);
|
|
168
|
-
if (att) peers.push({ peerId: att.peerId, role: att.role, meta: att.meta });
|
|
172
|
+
if (att) peers.push({ peerId: att.peerId, role: att.role, meta: att.meta, since: att.since });
|
|
169
173
|
}
|
|
170
174
|
return peers;
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
private broadcastPeers(): void {
|
|
174
|
-
const message: ServerMessage = { type: "peers", peers: this.peerList() };
|
|
178
|
+
const message: ServerMessage = { type: "peers", peers: this.peerList(), now: Date.now() };
|
|
175
179
|
const payload = JSON.stringify(message);
|
|
176
180
|
for (const ws of this.state.getWebSockets()) {
|
|
177
181
|
try {
|