codehost 0.20.5 → 0.22.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 +14 -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 +204 -20
- package/src/web/room-client.ts +2 -2
- package/worker/room.ts +7 -3
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));
|
|
@@ -154,6 +196,50 @@ function RoomClient(props: {
|
|
|
154
196
|
return null;
|
|
155
197
|
}
|
|
156
198
|
|
|
199
|
+
/** A copy-to-clipboard command row: label, the command, and a Copy button. */
|
|
200
|
+
function CopyCommand({ label, command }: { label: string; command: string }) {
|
|
201
|
+
const [copied, setCopied] = useState(false);
|
|
202
|
+
const copy = async () => {
|
|
203
|
+
try {
|
|
204
|
+
await navigator.clipboard.writeText(command);
|
|
205
|
+
} catch {
|
|
206
|
+
// clipboard blocked (insecure context / permission) — fall back to prompt
|
|
207
|
+
window.prompt("Copy this command:", command);
|
|
208
|
+
}
|
|
209
|
+
setCopied(true);
|
|
210
|
+
setTimeout(() => setCopied(false), 1500);
|
|
211
|
+
};
|
|
212
|
+
return (
|
|
213
|
+
<div style={styles.cmdRow}>
|
|
214
|
+
<span style={styles.cmdLabel}>{label}</span>
|
|
215
|
+
<code style={styles.cmdCode}>{command}</code>
|
|
216
|
+
<button style={styles.cmdCopy} onClick={copy}>
|
|
217
|
+
{copied ? "Copied!" : "Copy"}
|
|
218
|
+
</button>
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* "Set up a machine" card: the one-liner that turns any machine into a codehost
|
|
225
|
+
* server. The script bootstraps everything (Bun, the CLI, VS Code, the daemon),
|
|
226
|
+
* so the user needs no prerequisites — not even Bun. setup.sh/.ps1 are aliases
|
|
227
|
+
* of install.* served by Pages (see public/_redirects).
|
|
228
|
+
*/
|
|
229
|
+
function SetupCard() {
|
|
230
|
+
return (
|
|
231
|
+
<div style={styles.setupCard}>
|
|
232
|
+
<div style={styles.setupHead}>Set up a machine</div>
|
|
233
|
+
<p style={styles.setupSub}>
|
|
234
|
+
Run this on a machine to serve it here. It installs everything — Bun, VS Code, and the
|
|
235
|
+
codehost daemon — no prerequisites, and it picks a token and opens the browser for you.
|
|
236
|
+
</p>
|
|
237
|
+
<CopyCommand label="macOS / Linux" command="curl -fsSL https://codehost.dev/setup.sh | sh" />
|
|
238
|
+
<CopyCommand label="Windows" command={'powershell -c "irm codehost.dev/setup.ps1 | iex"'} />
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
157
243
|
export function Discovery() {
|
|
158
244
|
// Joined rooms — each token *is* a room id, and we keep one live signaling
|
|
159
245
|
// client per room (see RoomClient). Seeded from the persisted room list plus
|
|
@@ -170,6 +256,12 @@ export function Discovery() {
|
|
|
170
256
|
// Per-room discovery state, merged into one workspace list below.
|
|
171
257
|
const [serversByRoom, setServersByRoom] = useState<Record<string, PeerInfo[]>>({});
|
|
172
258
|
const [roomOpen, setRoomOpen] = useState<Record<string, boolean>>({});
|
|
259
|
+
// Other clients (browsers) per room, for the "In this room" roster, plus the
|
|
260
|
+
// worker clock from the latest peers message for relative join times.
|
|
261
|
+
const [clientsByRoom, setClientsByRoom] = useState<Record<string, PeerInfo[]>>({});
|
|
262
|
+
const [roomNow, setRoomNow] = useState<number | undefined>(undefined);
|
|
263
|
+
// This tab's roster label, computed once.
|
|
264
|
+
const labelRef = useRef(clientLabel());
|
|
173
265
|
|
|
174
266
|
// Token input = "join another room": validated, then appended to the set.
|
|
175
267
|
// Never pre-filled with a saved token — it's a bearer secret.
|
|
@@ -204,6 +296,12 @@ export function Discovery() {
|
|
|
204
296
|
const activePeerRef = useRef<string | null>(null);
|
|
205
297
|
const activeRoomRef = useRef<string | null>(null);
|
|
206
298
|
const sendersRef = useRef<Map<string, (to: string, data: unknown) => void>>(new Map());
|
|
299
|
+
// Admission control: the host can hold ("pending") or reject ("denied") us.
|
|
300
|
+
// `deniedRef` stops a trailing pc state change from overwriting the denied UI;
|
|
301
|
+
// the timer/reject refs let a control signal extend or abort the dial attempt.
|
|
302
|
+
const deniedRef = useRef(false);
|
|
303
|
+
const dialTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
304
|
+
const dialRejectRef = useRef<((e: Error) => void) | null>(null);
|
|
207
305
|
// Whether the live connection pushed a history entry (so Disconnect/Back can
|
|
208
306
|
// pop it back to the list).
|
|
209
307
|
const pushedRef = useRef(false);
|
|
@@ -387,6 +485,7 @@ export function Discovery() {
|
|
|
387
485
|
const send = sendersRef.current.get(room);
|
|
388
486
|
if (!send) return;
|
|
389
487
|
dialingRef.current = true; // synchronous gate against concurrent triggers
|
|
488
|
+
deniedRef.current = false;
|
|
390
489
|
let didPush = false;
|
|
391
490
|
try {
|
|
392
491
|
// Clear any prior connection's broker state first: after an RTC drop the
|
|
@@ -435,23 +534,25 @@ export function Discovery() {
|
|
|
435
534
|
const rtc = new RtcClient({
|
|
436
535
|
sendSignal: (data: RtcSignal) => send(server.peerId, data),
|
|
437
536
|
onState: (state) => {
|
|
438
|
-
if (state === "failed" || state === "disconnected") setConnState("failed");
|
|
537
|
+
if ((state === "failed" || state === "disconnected") && !deniedRef.current) setConnState("failed");
|
|
439
538
|
},
|
|
440
539
|
onOpen: (channel) => {
|
|
441
|
-
clearTimeout(
|
|
540
|
+
if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
|
|
442
541
|
resolve({ channel, bulk: rtc.bulkChannel });
|
|
443
542
|
},
|
|
444
543
|
onClose: () => setConnState((s) => (s === "connected" ? "idle" : s)),
|
|
445
544
|
});
|
|
446
545
|
rtcRef.current = rtc;
|
|
546
|
+
dialRejectRef.current = reject;
|
|
447
547
|
// 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
|
-
|
|
548
|
+
// server still listed in the room): fail the attempt after 15s. A
|
|
549
|
+
// "pending" admission signal swaps this for a longer approval window.
|
|
550
|
+
dialTimerRef.current = setTimeout(() => {
|
|
450
551
|
rtc.close();
|
|
451
552
|
reject(new Error("connection timed out"));
|
|
452
553
|
}, 15000);
|
|
453
554
|
rtc.start().catch((err) => {
|
|
454
|
-
clearTimeout(
|
|
555
|
+
if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
|
|
455
556
|
reject(err);
|
|
456
557
|
});
|
|
457
558
|
});
|
|
@@ -495,7 +596,7 @@ export function Discovery() {
|
|
|
495
596
|
setResolving(null);
|
|
496
597
|
recordConnect(server, room, openFolder);
|
|
497
598
|
} catch {
|
|
498
|
-
setConnState("failed");
|
|
599
|
+
setConnState(deniedRef.current ? "denied" : "failed");
|
|
499
600
|
// Undo the optimistic history entry we pushed. revertingRef makes the
|
|
500
601
|
// resulting popstate a no-op so the "failed" card stays on the list.
|
|
501
602
|
if (didPush) {
|
|
@@ -717,6 +818,12 @@ export function Discovery() {
|
|
|
717
818
|
const onlineRooms = tokens.filter((t) => roomOpen[t]).length;
|
|
718
819
|
const activeServer = allServers.find((x) => x.server.peerId === activePeerId)?.server;
|
|
719
820
|
|
|
821
|
+
// Other clients (browsers) across all joined rooms, deduped by peerId — the
|
|
822
|
+
// "In this room" roster, so you can spot a device that shouldn't hold a token.
|
|
823
|
+
const otherClients = Object.values(clientsByRoom)
|
|
824
|
+
.flat()
|
|
825
|
+
.filter((c, i, all) => all.findIndex((x) => x.peerId === c.peerId) === i);
|
|
826
|
+
|
|
720
827
|
// Annotate each server with its mnemonic fake-tags (incl. its room label), then
|
|
721
828
|
// filter. The room token is hashed to a short label — never rendered raw.
|
|
722
829
|
const tagged = allServers.map(({ server: s, room }) => ({
|
|
@@ -764,10 +871,39 @@ export function Discovery() {
|
|
|
764
871
|
<RoomClient
|
|
765
872
|
key={t}
|
|
766
873
|
token={t}
|
|
874
|
+
label={labelRef.current}
|
|
767
875
|
onPeers={(peers) => setServersByRoom((m) => ({ ...m, [t]: peers }))}
|
|
876
|
+
onRoster={(clients, now) => {
|
|
877
|
+
setClientsByRoom((m) => ({ ...m, [t]: clients }));
|
|
878
|
+
if (now) setRoomNow(now);
|
|
879
|
+
}}
|
|
768
880
|
onStatus={(open) => setRoomOpen((m) => ({ ...m, [t]: open }))}
|
|
769
881
|
onSignal={(from, data) => {
|
|
770
|
-
if (from
|
|
882
|
+
if (from !== activePeerRef.current) return;
|
|
883
|
+
const kind = (data as { kind?: string } | null)?.kind;
|
|
884
|
+
if (kind === "pending") {
|
|
885
|
+
// Host is reviewing us — show "waiting" and stop the short dial timer
|
|
886
|
+
// from failing the attempt while a human decides.
|
|
887
|
+
setConnState("pending");
|
|
888
|
+
if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
|
|
889
|
+
dialTimerRef.current = setTimeout(() => {
|
|
890
|
+
rtcRef.current?.close();
|
|
891
|
+
dialRejectRef.current?.(new Error("approval timed out"));
|
|
892
|
+
}, 120000);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (kind === "denied") {
|
|
896
|
+
// Denied while dialing, or kicked after connecting — set state directly
|
|
897
|
+
// so it covers both (the dial promise may already be settled).
|
|
898
|
+
deniedRef.current = true;
|
|
899
|
+
if (dialTimerRef.current) clearTimeout(dialTimerRef.current);
|
|
900
|
+
rtcRef.current?.close();
|
|
901
|
+
setIframeSrc(null);
|
|
902
|
+
setConnState("denied");
|
|
903
|
+
dialRejectRef.current?.(new Error("host denied the connection"));
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
void rtcRef.current?.handleSignal(data);
|
|
771
907
|
}}
|
|
772
908
|
registerSender={(send) => {
|
|
773
909
|
if (send) sendersRef.current.set(t, send);
|
|
@@ -978,12 +1114,10 @@ export function Discovery() {
|
|
|
978
1114
|
</span>
|
|
979
1115
|
)}
|
|
980
1116
|
</div>
|
|
981
|
-
{tokens.length === 0 && <p style={styles.dim}>Join a room to see your workspaces.</p>}
|
|
982
1117
|
{tokens.length > 0 && serverCount === 0 && (
|
|
983
|
-
<p style={styles.dim}>
|
|
984
|
-
No servers online. Run <code style={styles.code}>bunx codehost serve -t <token></code> on a machine.
|
|
985
|
-
</p>
|
|
1118
|
+
<p style={styles.dim}>No servers online in your rooms yet.</p>
|
|
986
1119
|
)}
|
|
1120
|
+
{serverCount === 0 && <SetupCard />}
|
|
987
1121
|
{serverCount > 0 && (
|
|
988
1122
|
<>
|
|
989
1123
|
<input
|
|
@@ -1078,18 +1212,20 @@ export function Discovery() {
|
|
|
1078
1212
|
)}
|
|
1079
1213
|
<div style={styles.idLine}>peer {s.peerId.slice(0, 8)}</div>
|
|
1080
1214
|
{isActive && (
|
|
1081
|
-
<div style={styles.echo}>
|
|
1215
|
+
<div style={connState === "denied" ? styles.echoBad : styles.echo}>
|
|
1082
1216
|
{connState === "connecting" && "negotiating WebRTC…"}
|
|
1217
|
+
{connState === "pending" && "waiting for the host to approve you…"}
|
|
1083
1218
|
{connState === "failed" && "connection failed"}
|
|
1219
|
+
{connState === "denied" && "the host denied this connection"}
|
|
1084
1220
|
</div>
|
|
1085
1221
|
)}
|
|
1086
1222
|
</div>
|
|
1087
1223
|
<button
|
|
1088
1224
|
style={styles.connectBtn}
|
|
1089
1225
|
onClick={() => connectTo(s, room)}
|
|
1090
|
-
disabled={isActive && connState === "connecting"}
|
|
1226
|
+
disabled={isActive && (connState === "connecting" || connState === "pending")}
|
|
1091
1227
|
>
|
|
1092
|
-
{isActive && connState === "connecting" ? "…" : "Connect"}
|
|
1228
|
+
{isActive && (connState === "connecting" || connState === "pending") ? "…" : "Connect"}
|
|
1093
1229
|
</button>
|
|
1094
1230
|
</li>
|
|
1095
1231
|
);
|
|
@@ -1101,6 +1237,40 @@ export function Discovery() {
|
|
|
1101
1237
|
<p style={styles.dim}>No workspace matches your filter.</p>
|
|
1102
1238
|
)}
|
|
1103
1239
|
</div>
|
|
1240
|
+
|
|
1241
|
+
{tokens.length > 0 && (
|
|
1242
|
+
<section style={styles.rosterSection}>
|
|
1243
|
+
<div style={styles.rosterHead}>
|
|
1244
|
+
In this room · you{otherClients.length > 0 ? ` + ${otherClients.length} other ${otherClients.length === 1 ? "client" : "clients"}` : ""}
|
|
1245
|
+
</div>
|
|
1246
|
+
<ul style={styles.list}>
|
|
1247
|
+
<li style={styles.personRow}>
|
|
1248
|
+
<span style={styles.personDot}>●</span>
|
|
1249
|
+
<span style={styles.personName}>{labelRef.current}</span>
|
|
1250
|
+
<span style={styles.dim}>you</span>
|
|
1251
|
+
</li>
|
|
1252
|
+
{otherClients.map((c) => {
|
|
1253
|
+
const age = relTime(c.since, roomNow);
|
|
1254
|
+
return (
|
|
1255
|
+
<li key={c.peerId} style={styles.personRow}>
|
|
1256
|
+
<span style={{ ...styles.personDot, color: "#dcb67a" }}>●</span>
|
|
1257
|
+
<span style={styles.personName}>{c.meta?.name ?? c.peerId.slice(0, 8)}</span>
|
|
1258
|
+
{age && <span style={styles.dim}>connected {age} ago</span>}
|
|
1259
|
+
</li>
|
|
1260
|
+
);
|
|
1261
|
+
})}
|
|
1262
|
+
</ul>
|
|
1263
|
+
<p style={styles.rosterHint}>
|
|
1264
|
+
Anyone holding a room's token can appear here and gets a full VS Code session
|
|
1265
|
+
(terminal + file write). See a device you don't recognize? Rotate the token with{" "}
|
|
1266
|
+
<code style={styles.code}>codehost setup --new-token</code>.
|
|
1267
|
+
</p>
|
|
1268
|
+
</section>
|
|
1269
|
+
)}
|
|
1270
|
+
|
|
1271
|
+
{/* When servers already exist the empty-state card is hidden, so keep an
|
|
1272
|
+
"add another machine" affordance available down here. */}
|
|
1273
|
+
{serverCount > 0 && <SetupCard />}
|
|
1104
1274
|
</main>
|
|
1105
1275
|
</div>
|
|
1106
1276
|
</>
|
|
@@ -1185,6 +1355,20 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
1185
1355
|
cardSub: { display: "flex", gap: 12, fontSize: 12, color: "#888", marginTop: 2 },
|
|
1186
1356
|
cwd: { fontFamily: "monospace" },
|
|
1187
1357
|
echo: { marginTop: 6, fontSize: 12, color: "#4ec9b0", fontFamily: "monospace" },
|
|
1358
|
+
echoBad: { marginTop: 6, fontSize: 12, color: "#f48771", fontFamily: "monospace" },
|
|
1359
|
+
rosterSection: { marginTop: 28 },
|
|
1360
|
+
rosterHead: { fontSize: 14, color: "#aaa", fontWeight: 600, margin: "0 0 12px" },
|
|
1361
|
+
setupCard: { marginTop: 20, background: "#252525", border: "1px solid #3d3d3d", borderRadius: 8, padding: "16px 18px" },
|
|
1362
|
+
setupHead: { fontSize: 15, color: "#fff", fontWeight: 600, marginBottom: 6 },
|
|
1363
|
+
setupSub: { fontSize: 13, color: "#aaa", margin: "0 0 14px", lineHeight: 1.5 },
|
|
1364
|
+
cmdRow: { display: "flex", alignItems: "center", gap: 10, marginTop: 8 },
|
|
1365
|
+
cmdLabel: { fontSize: 11, color: "#888", width: 88, flexShrink: 0 },
|
|
1366
|
+
cmdCode: { flex: 1, minWidth: 0, background: "#1b1b1b", border: "1px solid #3d3d3d", borderRadius: 6, padding: "8px 10px", fontFamily: "monospace", fontSize: 12.5, color: "#dcdcaa", overflow: "auto", whiteSpace: "nowrap" },
|
|
1367
|
+
cmdCopy: { flexShrink: 0, background: "#0e639c", border: "none", color: "#fff", padding: "8px 12px", borderRadius: 6, cursor: "pointer", fontSize: 12 },
|
|
1368
|
+
rosterHint: { margin: "10px 0 0", fontSize: 12, color: "#888" },
|
|
1369
|
+
personRow: { display: "flex", alignItems: "center", gap: 10, background: "#252525", border: "1px solid #3d3d3d", borderRadius: 8, padding: "8px 14px", fontSize: 13 },
|
|
1370
|
+
personDot: { color: "#4ec9b0", fontSize: 10 },
|
|
1371
|
+
personName: { color: "#eee", flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
|
1188
1372
|
connectBtn: { background: "#0e639c", border: "none", color: "#fff", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
1189
1373
|
shareBtn: { background: "transparent", border: "1px solid #3d3d3d", color: "#ccc", padding: "6px 14px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
|
|
1190
1374
|
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 {
|