From a5fb28914763fc5350497e70f965fbd0d7141801 Mon Sep 17 00:00:00 2001 From: Roberto Morado Date: Thu, 21 May 2026 00:51:43 -0400 Subject: [PATCH] v2.0 --- .claude/settings.local.json | 8 +- main.ts | 1703 ++++++++++++++++++++--------------- 2 files changed, 997 insertions(+), 714 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 545fbdb..a0820f3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,13 @@ "permissions": { "allow": [ "Bash(awk 'NR>=836 && NR<=1230' /Users/roramigator/p2p/p2p.ts)", - "Bash(awk 'NR>=122 && NR<=165' /Users/roramigator/p2p/p2p.ts)" + "Bash(awk 'NR>=122 && NR<=165' /Users/roramigator/p2p/p2p.ts)", + "Bash(deno run *)", + "Bash(curl -s http://localhost:8080/)", + "Bash(curl -s -X POST http://localhost:8080/session)", + "Bash(python3 -m json.tool)", + "Bash(pkill -f \"deno run\")", + "Bash(deno check *)" ] } } diff --git a/main.ts b/main.ts index 610b849..a555a84 100644 --- a/main.ts +++ b/main.ts @@ -1,11 +1,12 @@ // ───────────────────────────────────────────── +// drop. — anonymous P2P chat & file share // Single-file Deno server · WebRTC + SSE // run: deno run --allow-net main.ts // ───────────────────────────────────────────── -const PORT = Number(Deno.env.get("PORT")) ?? 8080; +const PORT = Number(Deno.env.get("PORT")) || 8080; const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour -const CHUNK_SIZE = 16 * 1024; // 16KB DataChannel chunks +const CHUNK_SIZE = 16 * 1024; // 16 KB DataChannel chunks const NAME = ".env"; // ── Types ──────────────────────────────────── @@ -14,6 +15,7 @@ interface Peer { id: string; controller: ReadableStreamDefaultController; lastSeen: number; + ip: string; } interface Session { @@ -23,6 +25,18 @@ interface Session { lastActivity: number; } +interface PortForward { + id: string; + localPort: number; + targetHost: string; + targetPort: number; + listener: Deno.Listener; + connCount: number; +} + +// sessionId → forwardId → PortForward +const portForwards = new Map>(); + // ── State ──────────────────────────────────── const sessions = new Map(); @@ -34,83 +48,60 @@ function touch(session: Session) { function generateId(len = 6): string { const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; let id = ""; - for (let i = 0; i < len; i++) { - id += chars[Math.floor(Math.random() * chars.length)]; - } + for (let i = 0; i < len; i++) id += chars[Math.floor(Math.random() * chars.length)]; return id; } function generateSessionId(): string { let id: string; - do { - id = generateId(4) + "-" + generateId(4); - } while (sessions.has(id)); + do { id = generateId(4) + "-" + generateId(4); } while (sessions.has(id)); return id; } -function generatePeerId(): string { - return generateId(8); -} +function generatePeerId(): string { return generateId(8); } function createSession(): Session { const id = generateSessionId(); - const session: Session = { - id, - peers: new Map(), - createdAt: Date.now(), - lastActivity: Date.now(), - }; + const session: Session = { id, peers: new Map(), createdAt: Date.now(), lastActivity: Date.now() }; sessions.set(id, session); return session; } const enc = new TextEncoder(); -function broadcast( - session: Session, - fromPeerId: string, - event: string, - data: unknown, -) { - const payload = enc.encode( - `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`, - ); +function broadcast(session: Session, fromPeerId: string, event: string, data: unknown) { + const payload = enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); for (const [pid, peer] of session.peers) { if (pid !== fromPeerId) { - try { - peer.controller.enqueue(payload); - peer.lastSeen = Date.now(); - } catch (_) { - // peer disconnected - } + try { peer.controller.enqueue(payload); peer.lastSeen = Date.now(); } catch (_) {} } } } -function sendToPeer( - session: Session, - toPeerId: string, - event: string, - data: unknown, -) { +function sendToPeer(session: Session, toPeerId: string, event: string, data: unknown) { const peer = session.peers.get(toPeerId); if (!peer) return; - try { - peer.controller.enqueue( - enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), - ); - peer.lastSeen = Date.now(); - } catch (_) { - // peer disconnected - } + try { peer.controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); peer.lastSeen = Date.now(); } catch (_) {} } function sendToSelf(peer: Peer, event: string, data: unknown) { - try { - peer.controller.enqueue( - enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), - ); - } catch (_) { /* ignore */ } + try { peer.controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); } catch (_) {} +} + +function sendToAll(session: Session, event: string, data: unknown) { + const payload = enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + for (const peer of session.peers.values()) { + try { peer.controller.enqueue(payload); peer.lastSeen = Date.now(); } catch (_) {} + } +} + +function cleanupPortForwards(sessionId: string) { + const fwds = portForwards.get(sessionId); + if (!fwds) return; + for (const fwd of fwds.values()) { + try { fwd.listener.close(); } catch (_) {} + } + portForwards.delete(sessionId); } function removePeer(session: Session, peerId: string) { @@ -118,22 +109,19 @@ function removePeer(session: Session, peerId: string) { touch(session); if (session.peers.size === 0) { sessions.delete(session.id); + cleanupPortForwards(session.id); } else { broadcast(session, peerId, "peer-left", { peerId }); } } -// Cleanup expired sessions every 10 minutes setInterval(() => { const now = Date.now(); for (const [id, session] of sessions) { if (now - session.lastActivity > SESSION_TTL_MS) { - for (const peer of session.peers.values()) { - try { - peer.controller.close(); - } catch (_) { /* ignore */ } - } + for (const peer of session.peers.values()) { try { peer.controller.close(); } catch (_) {} } sessions.delete(id); + cleanupPortForwards(id); } } }, 10 * 60 * 1000); @@ -142,19 +130,11 @@ setInterval(() => { const rlStore = new Map(); -function rateLimit( - ip: string, - key: string, - max: number, - windowMs = 60_000, -): boolean { +function rateLimit(ip: string, key: string, max: number, windowMs = 60_000): boolean { const k = ip + ":" + key; const now = Date.now(); const e = rlStore.get(k); - if (!e || now > e.reset) { - rlStore.set(k, { count: 1, reset: now + windowMs }); - return true; - } + if (!e || now > e.reset) { rlStore.set(k, { count: 1, reset: now + windowMs }); return true; } if (e.count >= max) return false; e.count++; return true; @@ -165,51 +145,56 @@ setInterval(() => { for (const [k, e] of rlStore) if (now > e.reset) rlStore.delete(k); }, 5 * 60 * 1000); +// ── TCP Proxy ──────────────────────────────── + +async function handleTcpConnection(client: Deno.Conn, fwd: PortForward): Promise { + let target: Deno.TcpConn; + try { + target = await Deno.connect({ hostname: fwd.targetHost, port: fwd.targetPort }); + } catch (_) { + try { client.close(); } catch (_) {} + return; + } + const pipe = (r: ReadableStream, w: WritableStream) => r.pipeTo(w).catch(() => {}); + try { + await Promise.race([ + pipe(client.readable, target.writable), + pipe(target.readable, client.writable), + ]); + } finally { + try { client.close(); } catch (_) {} + try { target.close(); } catch (_) {} + } +} + // ── HTTP Router ────────────────────────────── async function handler(req: Request, ip: string): Promise { const url = new URL(req.url); const path = url.pathname; - // GET / → HTML app shell if (req.method === "GET" && path === "/") { - return new Response(HTML, { - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); + return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } }); } - // POST /session → create new session if (req.method === "POST" && path === "/session") { - if (!rateLimit(ip, "session-create", 10)) { - return json({ error: "Too many requests" }, 429); - } + if (!rateLimit(ip, "session-create", 10)) return json({ error: "Too many requests" }, 429); const session = createSession(); return json({ sessionId: session.id }); } - // GET /session/:id/exists → check if session exists if (req.method === "GET" && path.match(/^\/session\/[^/]+\/exists$/)) { - if (!rateLimit(ip, "session-check", 30)) { - return json({ error: "Too many requests" }, 429); - } + if (!rateLimit(ip, "session-check", 30)) return json({ error: "Too many requests" }, 429); const sessionId = path.split("/")[2]; return json({ exists: sessions.has(sessionId) }); } - // GET /connect/:sessionId → SSE stream, join session if (req.method === "GET" && path.match(/^\/connect\/[^/]+$/)) { - if (!rateLimit(ip, "connect", 10)) { - return json({ error: "Too many requests" }, 429); - } + if (!rateLimit(ip, "connect", 10)) return json({ error: "Too many requests" }, 429); const sessionId = path.split("/")[2]; const session = sessions.get(sessionId); - if (!session) { - return json({ error: "Session not found" }, 404); - } - - if (session.peers.size >= 2) { - return json({ error: "Session full" }, 403); - } + if (!session) return json({ error: "Session not found" }, 404); + if (session.peers.size >= 2) return json({ error: "Session full" }, 403); const peerId = generatePeerId(); const isHost = session.peers.size === 0; @@ -217,37 +202,38 @@ async function handler(req: Request, ip: string): Promise { let controller!: ReadableStreamDefaultController; let heartbeat: ReturnType; const stream = new ReadableStream({ - start(c) { - controller = c; - }, - cancel() { - clearInterval(heartbeat); - removePeer(session!, peerId); - }, + start(c) { controller = c; }, + cancel() { clearInterval(heartbeat); removePeer(session!, peerId); }, }); - const peer: Peer = { id: peerId, controller, lastSeen: Date.now() }; + const peer: Peer = { id: peerId, controller, lastSeen: Date.now(), ip }; session.peers.set(peerId, peer); touch(session); heartbeat = setInterval(() => { - try { - peer.controller.enqueue(enc.encode("event: heartbeat\ndata: {}\n\n")); - } catch (_) { - clearInterval(heartbeat); - } + try { peer.controller.enqueue(enc.encode("event: heartbeat\ndata: {}\n\n")); } + catch (_) { clearInterval(heartbeat); } }, 30_000); - // Send welcome setTimeout(() => { + // Find other peer's IP for the joiner + let peerIp: string | null = null; + if (!isHost) { + for (const [pid, p] of session!.peers) { + if (pid !== peerId) { peerIp = p.ip; break; } + } + } + const fwdMap = portForwards.get(sessionId); + const currentFwds = fwdMap + ? Array.from(fwdMap.values()).map(f => ({ id: f.id, localPort: f.localPort, targetHost: f.targetHost, targetPort: f.targetPort })) + : []; sendToSelf(peer, "welcome", { - peerId, - sessionId, - isHost, - chunkSize: CHUNK_SIZE, + peerId, sessionId, isHost, chunkSize: CHUNK_SIZE, + ip, peerIp, createdAt: session!.createdAt, + portForwards: currentFwds, }); if (!isHost) { - broadcast(session!, peerId, "peer-joined", { peerId }); + broadcast(session!, peerId, "peer-joined", { peerId, ip }); } }, 50); @@ -261,36 +247,92 @@ async function handler(req: Request, ip: string): Promise { }); } - // POST /signal/:sessionId → WebRTC signaling relay if (req.method === "POST" && path.match(/^\/signal\/[^/]+$/)) { - if (!rateLimit(ip, "signal", 200)) { - return json({ error: "Too many requests" }, 429); - } + if (!rateLimit(ip, "signal", 200)) return json({ error: "Too many requests" }, 429); const sessionId = path.split("/")[2]; const session = sessions.get(sessionId); if (!session) return json({ error: "Session not found" }, 404); - const body = await req.json(); const { fromPeerId, toPeerId, type, payload } = body; touch(session); - - if (toPeerId) { - sendToPeer(session, toPeerId, "signal", { fromPeerId, type, payload }); - } else { - broadcast(session, fromPeerId, "signal", { fromPeerId, type, payload }); - } - + if (toPeerId) sendToPeer(session, toPeerId, "signal", { fromPeerId, type, payload }); + else broadcast(session, fromPeerId, "signal", { fromPeerId, type, payload }); return json({ ok: true }); } - // POST /leave/:sessionId if (req.method === "POST" && path.match(/^\/leave\/[^/]+$/)) { const sessionId = path.split("/")[2]; const session = sessions.get(sessionId); - if (session) { - const body = await req.json(); - removePeer(session, body.peerId); + if (session) { const body = await req.json(); removePeer(session, body.peerId); } + return json({ ok: true }); + } + + // ── Port Forward: open ──────────────────────── + if (req.method === "POST" && path.match(/^\/pf\/[^/]+\/open$/)) { + if (!rateLimit(ip, "pf-open", 20)) return json({ error: "Too many requests" }, 429); + const sessionId = path.split("/")[2]; + const session = sessions.get(sessionId); + if (!session) return json({ error: "Session not found" }, 404); + + const body = await req.json(); + const { localPort, targetHost, targetPort } = body; + if (!Number.isInteger(localPort) || localPort < 1024 || localPort > 65535) + return json({ error: "Local port must be 1024–65535" }, 400); + if (!targetHost || typeof targetHost !== "string" || targetHost.length > 253) + return json({ error: "Invalid target host" }, 400); + if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) + return json({ error: "Target port must be 1–65535" }, 400); + + let fwdMap = portForwards.get(sessionId); + if (!fwdMap) { fwdMap = new Map(); portForwards.set(sessionId, fwdMap); } + for (const f of fwdMap.values()) { + if (f.localPort === localPort) return json({ error: `Port ${localPort} is already forwarded` }, 409); } + + let listener: Deno.Listener; + try { + listener = Deno.listen({ port: localPort, hostname: "127.0.0.1" }); + } catch (err) { + return json({ error: `Cannot bind port ${localPort}: ${err instanceof Error ? err.message : String(err)}` }, 500); + } + + const fwdId = generateId(8); + const fwd: PortForward = { id: fwdId, localPort, targetHost, targetPort, listener, connCount: 0 }; + fwdMap.set(fwdId, fwd); + + // Accept loop — fire and forget + (async () => { + try { + for await (const conn of listener) { + fwd.connCount++; + handleTcpConnection(conn, fwd).finally(() => { fwd.connCount = Math.max(0, fwd.connCount - 1); }); + } + } catch (_) { /* listener closed */ } + })(); + + touch(session); + sendToAll(session, "pf-open", { id: fwdId, localPort, targetHost, targetPort }); + return json({ ok: true, id: fwdId, localPort }); + } + + // ── Port Forward: close ─────────────────────── + if (req.method === "POST" && path.match(/^\/pf\/[^/]+\/close$/)) { + const sessionId = path.split("/")[2]; + const session = sessions.get(sessionId); + if (!session) return json({ error: "Session not found" }, 404); + + const body = await req.json(); + const { id } = body; + const fwdMap = portForwards.get(sessionId); + const fwd = fwdMap?.get(id); + if (!fwd) return json({ error: "Forward not found" }, 404); + + try { fwd.listener.close(); } catch (_) {} + fwdMap!.delete(id); + if (fwdMap!.size === 0) portForwards.delete(sessionId); + + touch(session); + sendToAll(session, "pf-close", { id }); return json({ ok: true }); } @@ -298,19 +340,13 @@ async function handler(req: Request, ip: string): Promise { } function json(data: unknown, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - headers: { "Content-Type": "application/json" }, - }); + return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } }); } // ── Start ──────────────────────────────────── console.log(`${NAME} running → http://localhost:${PORT}`); -Deno.serve( - { port: PORT }, - (req, info) => handler(req, (info.remoteAddr as Deno.NetAddr).hostname), -); +Deno.serve({ port: PORT }, (req, info) => handler(req, (info.remoteAddr as Deno.NetAddr).hostname)); // ── HTML / CSS / JS ────────────────────────── @@ -336,6 +372,7 @@ const HTML = ` --text-muted: #444; --accent-dim: #555; --danger: #ff4444; + --warn: #f59e0b; --success: #4ade80; --mono: 'DM Mono', monospace; --sans: 'DM Sans', sans-serif; @@ -358,483 +395,206 @@ const HTML = ` .screen.active { display: flex; } /* ── Landing ── */ - #screen-land { - flex-direction: column; - align-items: center; - justify-content: center; - gap: 0; - padding: 40px; - } + #screen-land { flex-direction: column; align-items: center; justify-content: center; gap: 0; padding: 40px; } - .wordmark { - font-family: var(--mono); - font-size: 52px; - font-weight: 300; - letter-spacing: -3px; - color: var(--text); - margin-bottom: 10px; - opacity: 0; - animation: fadeUp 600ms 100ms ease forwards; - } - - .tagline { - font-size: 12px; - font-weight: 200; - color: var(--text-muted); - letter-spacing: 0.18em; - margin-bottom: 72px; - opacity: 0; - animation: fadeUp 600ms 200ms ease forwards; - } - - .land-actions { - display: flex; - flex-direction: column; - gap: 10px; - width: 100%; - max-width: 260px; - opacity: 0; - animation: fadeUp 600ms 300ms ease forwards; - } - - .btn { - display: flex; - align-items: center; - justify-content: center; - height: 42px; - border-radius: var(--radius); - font-family: var(--sans); - font-size: 13px; - font-weight: 400; - letter-spacing: 0.02em; - cursor: pointer; - transition: all var(--transition); - border: none; - outline: none; - } + .wordmark { font-family: var(--mono); font-size: 52px; font-weight: 300; letter-spacing: -3px; color: var(--text); margin-bottom: 10px; opacity: 0; animation: fadeUp 600ms 100ms ease forwards; } + .tagline { font-size: 12px; font-weight: 200; color: var(--text-muted); letter-spacing: 0.18em; margin-bottom: 72px; opacity: 0; animation: fadeUp 600ms 200ms ease forwards; } - .btn-primary { - background: var(--text); - color: var(--bg); - } + .land-actions { display: flex; flex-direction: column; gap: 10px; width: 100%; max-width: 260px; opacity: 0; animation: fadeUp 600ms 300ms ease forwards; } + + .btn { display: flex; align-items: center; justify-content: center; height: 42px; border-radius: var(--radius); font-family: var(--sans); font-size: 13px; font-weight: 400; letter-spacing: 0.02em; cursor: pointer; transition: all var(--transition); border: none; outline: none; } + .btn-primary { background: var(--text); color: var(--bg); } .btn-primary:hover { background: #cfcfcf; } .btn-primary:active { transform: scale(0.98); } - - .btn-ghost { - background: transparent; - color: var(--text-dim); - border: 1px solid var(--border); - } + .btn-ghost { background: transparent; color: var(--text-dim); border: 1px solid var(--border); } .btn-ghost:hover { border-color: var(--accent-dim); color: var(--text); } .btn-ghost:active { transform: scale(0.98); } - .divider { - display: flex; - align-items: center; - gap: 10px; - color: var(--text-muted); - font-size: 11px; - letter-spacing: 0.12em; - font-family: var(--mono); - margin: 4px 0; - } - .divider::before, .divider::after { - content: ''; - flex: 1; - height: 1px; - background: var(--border); - } - - .join-row { - display: flex; - gap: 8px; - width: 100%; - max-width: 260px; - opacity: 0; - animation: fadeUp 600ms 350ms ease forwards; - } - - .input-code { - flex: 1; - height: 42px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 0 14px; - font-family: var(--mono); - font-size: 13px; - color: var(--text); - letter-spacing: 0.14em; - outline: none; - transition: border-color var(--transition); - text-transform: uppercase; - } + .divider { display: flex; align-items: center; gap: 10px; color: var(--text-muted); font-size: 11px; letter-spacing: 0.12em; font-family: var(--mono); margin: 4px 0; } + .divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--border); } + + .join-row { display: flex; gap: 8px; width: 100%; max-width: 260px; opacity: 0; animation: fadeUp 600ms 350ms ease forwards; } + + .input-code { flex: 1; height: 42px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 0 14px; font-family: var(--mono); font-size: 13px; color: var(--text); letter-spacing: 0.14em; outline: none; transition: border-color var(--transition); text-transform: uppercase; } .input-code::placeholder { color: var(--text-muted); letter-spacing: 0.06em; text-transform: none; } .input-code:focus { border-color: var(--accent-dim); } - .land-footer { - position: fixed; - bottom: 28px; - font-size: 11px; - color: var(--text-muted); - font-family: var(--mono); - letter-spacing: 0.08em; - opacity: 0; - animation: fadeUp 600ms 500ms ease forwards; - } + .land-footer { position: fixed; bottom: 28px; font-size: 11px; color: var(--text-muted); font-family: var(--mono); letter-spacing: 0.08em; opacity: 0; animation: fadeUp 600ms 500ms ease forwards; } /* ── Waiting ── */ - #screen-wait { - flex-direction: column; - align-items: center; - justify-content: center; - gap: 36px; - padding: 40px; - } + #screen-wait { flex-direction: column; align-items: center; justify-content: center; gap: 36px; padding: 40px; } - .wait-label { - font-family: var(--mono); - font-size: 10px; - letter-spacing: 0.2em; - color: var(--text-muted); - text-transform: uppercase; - } - - .session-code-big { - font-family: var(--mono); - font-size: 38px; - font-weight: 300; - letter-spacing: 10px; - color: var(--text); - cursor: pointer; - transition: color var(--transition); - padding-left: 10px; /* optical offset for letter-spacing */ - } + .wait-label { font-family: var(--mono); font-size: 10px; letter-spacing: 0.2em; color: var(--text-muted); text-transform: uppercase; } + .session-code-big { font-family: var(--mono); font-size: 38px; font-weight: 300; letter-spacing: 10px; color: var(--text); cursor: pointer; transition: color var(--transition); padding-left: 10px; } .session-code-big:hover { color: var(--text-dim); } + .copy-hint { font-family: var(--mono); font-size: 11px; color: var(--text-muted); letter-spacing: 0.08em; margin-top: -24px; } - .copy-hint { - font-family: var(--mono); - font-size: 11px; - color: var(--text-muted); - letter-spacing: 0.08em; - margin-top: -24px; - } - - .pulse-ring { - width: 56px; - height: 56px; - border-radius: 50%; - border: 1px solid var(--border); - position: relative; - display: flex; - align-items: center; - justify-content: center; - } - .pulse-ring::before { - content: ''; - position: absolute; - width: 100%; - height: 100%; - border-radius: 50%; - border: 1px solid var(--accent-dim); - animation: pulse 2.2s ease infinite; - } - .pulse-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--text-muted); - } + .pulse-ring { width: 56px; height: 56px; border-radius: 50%; border: 1px solid var(--border); position: relative; display: flex; align-items: center; justify-content: center; } + .pulse-ring::before { content: ''; position: absolute; width: 100%; height: 100%; border-radius: 50%; border: 1px solid var(--accent-dim); animation: pulse 2.2s ease infinite; } + .pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); } /* ── Chat ── */ - #screen-chat { - flex-direction: column; - height: 100vh; - position: relative; - } - - .drop-overlay { - display: none; - position: absolute; - inset: 0; - background: rgba(14,14,14,0.88); - align-items: center; - justify-content: center; - z-index: 50; - pointer-events: none; - } - .drop-overlay.active { display: flex; } - .drop-hint { - font-family: var(--mono); - font-size: 13px; - letter-spacing: 0.12em; - color: var(--text-dim); - border: 1px dashed var(--border); - padding: 20px 36px; - border-radius: var(--radius); - } - - .chat-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 20px; - height: 50px; - border-bottom: 1px solid var(--border-soft); - flex-shrink: 0; - } - - .chat-wordmark { - font-family: var(--mono); - font-size: 17px; - font-weight: 300; - letter-spacing: -1px; - color: var(--text); - } - - .chat-meta { - display: flex; - align-items: center; - gap: 14px; - } + #screen-chat { flex-direction: column; height: 100vh; position: relative; overflow: hidden; } - .session-pill { - font-family: var(--mono); - font-size: 11px; - letter-spacing: 0.14em; - color: var(--text-muted); - background: var(--surface); - border: 1px solid var(--border); - padding: 3px 10px; - border-radius: 20px; - cursor: pointer; - transition: all var(--transition); - } + .drop-overlay { display: none; position: absolute; inset: 0; background: rgba(14,14,14,0.88); align-items: center; justify-content: center; z-index: 50; pointer-events: none; } + .drop-overlay.active { display: flex; } + .drop-hint { font-family: var(--mono); font-size: 13px; letter-spacing: 0.12em; color: var(--text-dim); border: 1px dashed var(--border); padding: 20px 36px; border-radius: var(--radius); } + + /* Header */ + .chat-header { display: flex; align-items: center; justify-content: space-between; padding: 0 14px 0 20px; height: 50px; border-bottom: 1px solid var(--border-soft); flex-shrink: 0; gap: 12px; } + .chat-wordmark { font-family: var(--mono); font-size: 17px; font-weight: 300; letter-spacing: -1px; color: var(--text); flex-shrink: 0; } + + /* Tab bar */ + .tab-bar { display: flex; gap: 1px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 3px; } + .tab { font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em; color: var(--text-muted); background: none; border: none; cursor: pointer; padding: 4px 12px; border-radius: 4px; transition: all var(--transition); white-space: nowrap; position: relative; } + .tab:hover { color: var(--text-dim); } + .tab.active { background: var(--surface2); color: var(--text); } + .files-badge { font-size: 9px; background: var(--accent-dim); color: var(--text); border-radius: 8px; padding: 1px 5px; margin-left: 4px; vertical-align: middle; display: none; } + + /* Chat meta */ + .chat-meta { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } + .session-countdown { font-family: var(--mono); font-size: 11px; color: var(--text-muted); letter-spacing: 0.04em; white-space: nowrap; } + .session-countdown.warn { color: var(--warn); } + .session-countdown.crit { color: var(--danger); animation: blink 1s ease infinite; } + .session-pill { font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em; color: var(--text-muted); background: var(--surface); border: 1px solid var(--border); padding: 3px 10px; border-radius: 20px; cursor: pointer; transition: all var(--transition); } .session-pill:hover { color: var(--text-dim); border-color: var(--accent-dim); } - - .status-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--success); - box-shadow: 0 0 6px color-mix(in srgb, var(--success) 60%, transparent); - } - - .btn-leave { - font-family: var(--mono); - font-size: 11px; - color: var(--text-muted); - background: none; - border: none; - cursor: pointer; - letter-spacing: 0.08em; - padding: 4px 0; - transition: color var(--transition); - } + .status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); box-shadow: 0 0 6px color-mix(in srgb, var(--success) 60%, transparent); flex-shrink: 0; } + .btn-leave { font-family: var(--mono); font-size: 11px; color: var(--text-muted); background: none; border: none; cursor: pointer; letter-spacing: 0.08em; padding: 4px 0; transition: color var(--transition); } .btn-leave:hover { color: var(--danger); } - .messages { - flex: 1; - overflow-y: auto; - padding: 20px; - display: flex; - flex-direction: column; - gap: 2px; - scroll-behavior: smooth; - } + /* Icon buttons */ + .icon-btn { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; border-radius: 7px; background: none; border: none; cursor: pointer; color: var(--text-muted); transition: all var(--transition); flex-shrink: 0; } + .icon-btn:hover { background: var(--surface2); color: var(--text); } + .icon-btn.send { background: var(--text); color: var(--bg); } + .icon-btn.send:hover { background: #cfcfcf; } + .icon-btn.send:active { transform: scale(0.9); } + .icon-btn svg { width: 14px; height: 14px; } + .icon-btn.active-btn { color: var(--text); background: var(--surface2); } + .icon-btn.burn-on { color: var(--danger); } + .icon-btn.burn-on:hover { background: rgba(255,68,68,0.1); color: var(--danger); } + + /* Peer info panel */ + .peer-info-panel { display: none; position: absolute; top: 54px; right: 14px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; z-index: 40; min-width: 230px; font-family: var(--mono); } + .peer-info-panel.open { display: block; } + .pi-title { font-size: 10px; letter-spacing: 0.14em; color: var(--text-muted); text-transform: uppercase; margin-bottom: 10px; } + .pi-row { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; font-size: 11px; border-bottom: 1px solid var(--border-soft); } + .pi-row:last-child { border-bottom: none; } + .pi-label { color: var(--text-muted); letter-spacing: 0.06em; } + .pi-val { color: var(--text); letter-spacing: 0.04em; } + + /* Panels */ + .panel-chat, .panel-files, .panel-ports { display: none; flex: 1; flex-direction: column; min-height: 0; overflow: hidden; } + .panel-chat.active, .panel-files.active, .panel-ports.active { display: flex; } + + /* Messages */ + .messages { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 2px; scroll-behavior: smooth; min-height: 0; } .messages::-webkit-scrollbar { width: 3px; } .messages::-webkit-scrollbar-track { background: transparent; } .messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } - .msg { - display: flex; - flex-direction: column; - max-width: 70%; - opacity: 0; - animation: msgIn 180ms ease forwards; - } + .msg { display: flex; flex-direction: column; max-width: 72%; opacity: 0; animation: msgIn 180ms ease forwards; } .msg.mine { align-self: flex-end; align-items: flex-end; } .msg.theirs { align-self: flex-start; align-items: flex-start; } .msg.system { align-self: center; align-items: center; max-width: 100%; } - .msg-bubble { - padding: 8px 13px; - border-radius: 14px; - font-size: 14px; - font-weight: 300; - line-height: 1.5; - word-break: break-word; - } + .msg-bubble { padding: 8px 13px; border-radius: 14px; font-size: 14px; font-weight: 300; line-height: 1.5; word-break: break-word; } + .msg.mine .msg-bubble { background: var(--surface2); color: var(--text); border-bottom-right-radius: 3px; } + .msg.theirs .msg-bubble { background: var(--surface); color: var(--text); border: 1px solid var(--border-soft); border-bottom-left-radius: 3px; } + .msg.system .msg-bubble { background: transparent; color: var(--text-muted); font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em; padding: 4px 0; margin: 6px 0; } - .msg.mine .msg-bubble { - background: var(--surface2); - color: var(--text); - border-bottom-right-radius: 3px; - } - .msg.theirs .msg-bubble { - background: var(--surface); - color: var(--text); - border: 1px solid var(--border-soft); - border-bottom-left-radius: 3px; - } - .msg.system .msg-bubble { - background: transparent; - color: var(--text-muted); - font-family: var(--mono); - font-size: 11px; - letter-spacing: 0.06em; - padding: 4px 0; - margin: 6px 0; - } - - .msg-time { - font-family: var(--mono); - font-size: 10px; - color: var(--text-muted); - margin-top: 3px; - padding: 0 4px; - } - - .msg-file { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 13px; - border-radius: 14px; - cursor: pointer; - transition: opacity var(--transition); - min-width: 210px; - } - .msg.mine .msg-file { - background: var(--surface2); - border-bottom-right-radius: 3px; - } - .msg.theirs .msg-file { - background: var(--surface); - border: 1px solid var(--border-soft); - border-bottom-left-radius: 3px; - } - .msg-file:hover { opacity: 0.75; } - - .file-icon { - width: 34px; - height: 34px; - border-radius: 7px; - background: var(--border); - display: flex; - align-items: center; - justify-content: center; - font-family: var(--mono); - font-size: 8px; - font-weight: 500; - color: var(--text-dim); - letter-spacing: 0.04em; - flex-shrink: 0; - } + .msg-time { font-family: var(--mono); font-size: 10px; color: var(--text-muted); margin-top: 3px; padding: 0 4px; } + /* File messages */ + .msg-file { display: flex; align-items: center; gap: 12px; padding: 10px 13px; border-radius: 14px; cursor: pointer; transition: opacity var(--transition); min-width: 210px; } + .msg.mine .msg-file { background: var(--surface2); border-bottom-right-radius: 3px; } + .msg.theirs .msg-file { background: var(--surface); border: 1px solid var(--border-soft); border-bottom-left-radius: 3px; } + .msg-file:hover { opacity: 0.75; } + .file-icon { width: 34px; height: 34px; border-radius: 7px; background: var(--border); display: flex; align-items: center; justify-content: center; font-family: var(--mono); font-size: 8px; font-weight: 500; color: var(--text-dim); letter-spacing: 0.04em; flex-shrink: 0; } .file-info { flex: 1; min-width: 0; } .file-name { font-size: 13px; font-weight: 400; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .file-size { font-family: var(--mono); font-size: 10px; color: var(--text-muted); margin-top: 2px; } .file-progress { width: 100%; height: 1px; background: var(--border); border-radius: 1px; margin-top: 7px; overflow: hidden; } .file-progress-bar { height: 100%; background: var(--text-dim); border-radius: 1px; transition: width 80ms linear; width: 0%; } - /* Input */ - .chat-input-area { - padding: 10px 14px 18px; - border-top: 1px solid var(--border-soft); - flex-shrink: 0; - } - - .input-row { - display: flex; - align-items: flex-end; - gap: 8px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: 12px; - padding: 8px 8px 8px 14px; - transition: border-color var(--transition); - } - .input-row:focus-within { border-color: var(--accent-dim); } - - .msg-input { - flex: 1; - background: none; - border: none; - outline: none; - font-family: var(--sans); - font-size: 14px; - font-weight: 300; - color: var(--text); - resize: none; - max-height: 120px; - line-height: 1.5; - padding: 2px 0; - } - .msg-input::placeholder { color: var(--text-muted); } - - .input-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; padding-bottom: 2px; } - - .icon-btn { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 8px; - background: none; - border: none; - cursor: pointer; - color: var(--text-muted); - transition: all var(--transition); - } - .icon-btn:hover { background: var(--surface2); color: var(--text); } - .icon-btn.send { background: var(--text); color: var(--bg); } - .icon-btn.send:hover { background: #cfcfcf; } - .icon-btn.send:active { transform: scale(0.9); } - .icon-btn svg { width: 15px; height: 15px; } - - #file-input { display: none; } + /* Clipboard message */ + .msg-clipboard { display: flex; align-items: flex-start; gap: 10px; padding: 10px 13px; border-radius: 14px; min-width: 210px; max-width: 100%; } + .msg.mine .msg-clipboard { background: var(--surface2); border-bottom-right-radius: 3px; } + .msg.theirs .msg-clipboard { background: var(--surface); border: 1px solid var(--border-soft); border-bottom-left-radius: 3px; } + .clip-label { font-family: var(--mono); font-size: 9px; color: var(--text-muted); background: var(--border); border-radius: 3px; padding: 2px 6px; flex-shrink: 0; letter-spacing: 0.06em; margin-top: 1px; } + .clip-content { flex: 1; font-family: var(--mono); font-size: 12px; color: var(--text-dim); white-space: pre-wrap; word-break: break-all; max-height: 120px; overflow: hidden; line-height: 1.5; } + .clip-copy-btn { font-family: var(--mono); font-size: 10px; color: var(--text-muted); background: var(--border); border: none; border-radius: 4px; padding: 3px 8px; cursor: pointer; white-space: nowrap; transition: all var(--transition); flex-shrink: 0; margin-top: 1px; } + .clip-copy-btn:hover { color: var(--text); background: var(--accent-dim); } + + /* Code block message */ + .msg-code { border-radius: 10px; overflow: hidden; min-width: 240px; } + .msg.mine .msg-code { border-bottom-right-radius: 3px; } + .msg.theirs .msg-code { border-bottom-left-radius: 3px; } + .code-header { display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; background: var(--border); font-family: var(--mono); font-size: 10px; color: var(--text-muted); letter-spacing: 0.08em; } + .code-copy-btn { background: none; border: none; color: var(--text-muted); cursor: pointer; font-family: var(--mono); font-size: 10px; padding: 2px 7px; border-radius: 3px; transition: all var(--transition); } + .code-copy-btn:hover { color: var(--text); background: var(--accent-dim); } + .code-body { margin: 0; padding: 12px; background: var(--surface2); font-family: var(--mono); font-size: 12px; color: var(--text); overflow-x: auto; line-height: 1.65; white-space: pre; } /* Transfer bar */ - .transfer-bar { - display: none; - align-items: center; - gap: 10px; - padding: 7px 14px; - border-top: 1px solid var(--border-soft); - font-family: var(--mono); - font-size: 11px; - color: var(--text-muted); - letter-spacing: 0.06em; - flex-shrink: 0; - } + .transfer-bar { display: none; align-items: center; gap: 10px; padding: 7px 14px; border-top: 1px solid var(--border-soft); font-family: var(--mono); font-size: 11px; color: var(--text-muted); letter-spacing: 0.06em; flex-shrink: 0; } .transfer-bar.active { display: flex; } .transfer-track { flex: 1; height: 1px; background: var(--border); border-radius: 1px; overflow: hidden; } .transfer-fill { height: 100%; background: var(--text-dim); border-radius: 1px; transition: width 80ms linear; width: 0%; } .transfer-cancel { width: 22px; height: 22px; flex-shrink: 0; } .transfer-cancel svg { width: 11px; height: 11px; } + /* Input area */ + .chat-input-area { padding: 10px 14px 18px; border-top: 1px solid var(--border-soft); flex-shrink: 0; } + .input-row { display: flex; align-items: flex-end; gap: 8px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 8px 8px 8px 14px; transition: border-color var(--transition); } + .input-row:focus-within { border-color: var(--accent-dim); } + .msg-input { flex: 1; background: none; border: none; outline: none; font-family: var(--sans); font-size: 14px; font-weight: 300; color: var(--text); resize: none; max-height: 120px; line-height: 1.5; padding: 2px 0; } + .msg-input::placeholder { color: var(--text-muted); } + .input-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; padding-bottom: 2px; } + #file-input { display: none; } + + /* Files panel */ + .files-toolbar { padding: 12px 16px; border-bottom: 1px solid var(--border-soft); flex-shrink: 0; display: flex; align-items: center; gap: 10px; } + .files-toolbar-hint { font-family: var(--mono); font-size: 10px; color: var(--text-muted); letter-spacing: 0.06em; } + .files-list { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; min-height: 0; } + .files-list::-webkit-scrollbar { width: 3px; } + .files-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + .files-empty { font-family: var(--mono); font-size: 11px; color: var(--text-muted); letter-spacing: 0.06em; text-align: center; padding: 48px 20px; } + .sfile-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } + .sfile-header { display: flex; align-items: center; padding: 8px 10px; gap: 8px; border-bottom: 1px solid var(--border-soft); } + .sfile-name { flex: 1; font-family: var(--mono); font-size: 12px; color: var(--text); background: none; border: 1px solid transparent; border-radius: 3px; outline: none; min-width: 0; padding: 1px 4px; cursor: default; transition: border-color var(--transition), background var(--transition); } + .sfile-name:hover { border-color: var(--border); background: var(--surface2); cursor: text; } + .sfile-name:focus { border-color: var(--accent-dim); background: var(--surface2); cursor: text; } + .sfile-actions { display: flex; gap: 4px; flex-shrink: 0; } + .sfile-btn { font-family: var(--mono); font-size: 10px; color: var(--text-muted); background: none; border: 1px solid var(--border); cursor: pointer; padding: 2px 8px; border-radius: 3px; transition: all var(--transition); letter-spacing: 0.04em; } + .sfile-btn:hover { color: var(--text); border-color: var(--accent-dim); } + .sfile-btn.del:hover { color: var(--danger); border-color: var(--danger); } + .sfile-editor { width: 100%; min-height: 90px; max-height: 280px; resize: vertical; background: none; border: none; outline: none; font-family: var(--mono); font-size: 12px; color: var(--text); padding: 10px 12px; line-height: 1.65; } + .sfile-editor::placeholder { color: var(--text-muted); } + + /* Port forwards panel */ + .pf-toolbar { padding: 12px 16px; border-bottom: 1px solid var(--border-soft); flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; } + .pf-form { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } + .pf-input { height: 32px; background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius); padding: 0 10px; font-family: var(--mono); font-size: 12px; color: var(--text); outline: none; transition: border-color var(--transition); } + .pf-input:focus { border-color: var(--accent-dim); } + .pf-input::placeholder { color: var(--text-muted); } + .pf-input.port { width: 90px; } + .pf-input.host { width: 140px; } + .pf-sep { font-family: var(--mono); font-size: 12px; color: var(--text-muted); flex-shrink: 0; } + .pf-hint { font-family: var(--mono); font-size: 10px; color: var(--text-muted); letter-spacing: 0.06em; } + .pf-list { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; min-height: 0; } + .pf-list::-webkit-scrollbar { width: 3px; } + .pf-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + .pf-empty { font-family: var(--mono); font-size: 11px; color: var(--text-muted); letter-spacing: 0.06em; text-align: center; padding: 48px 20px; } + .pf-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; display: flex; align-items: center; gap: 12px; } + .pf-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); box-shadow: 0 0 5px color-mix(in srgb, var(--success) 55%, transparent); flex-shrink: 0; } + .pf-rule { flex: 1; font-family: var(--mono); font-size: 12px; color: var(--text-dim); } + .pf-rule b { color: var(--text); font-weight: 500; } + .pf-conns { font-family: var(--mono); font-size: 10px; color: var(--text-muted); } + .pf-close-btn { font-family: var(--mono); font-size: 10px; color: var(--text-muted); background: none; border: 1px solid var(--border); cursor: pointer; padding: 2px 8px; border-radius: 3px; transition: all var(--transition); flex-shrink: 0; } + .pf-close-btn:hover { color: var(--danger); border-color: var(--danger); } + .ports-badge { font-size: 9px; background: var(--accent-dim); color: var(--text); border-radius: 8px; padding: 1px 5px; margin-left: 4px; vertical-align: middle; display: none; } + /* Toast */ - .toast { - position: fixed; - bottom: 76px; - left: 50%; - transform: translateX(-50%) translateY(8px); - background: var(--surface2); - border: 1px solid var(--border); - border-radius: 20px; - padding: 7px 16px; - font-family: var(--mono); - font-size: 11px; - color: var(--text-dim); - letter-spacing: 0.06em; - opacity: 0; - transition: all 220ms ease; - pointer-events: none; - white-space: nowrap; - z-index: 100; - } + .toast { position: fixed; bottom: 76px; left: 50%; transform: translateX(-50%) translateY(8px); background: var(--surface2); border: 1px solid var(--border); border-radius: 20px; padding: 7px 16px; font-family: var(--mono); font-size: 11px; color: var(--text-dim); letter-spacing: 0.06em; opacity: 0; transition: all 220ms ease; pointer-events: none; white-space: nowrap; z-index: 100; } .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } /* Error */ @@ -842,19 +602,10 @@ const HTML = ` .err-code { font-family: var(--mono); font-size: 60px; font-weight: 300; color: var(--text-muted); letter-spacing: -2px; } .err-msg { font-size: 13px; color: var(--text-dim); font-weight: 200; } - @keyframes fadeUp { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } - } - @keyframes pulse { - 0% { transform: scale(1); opacity: 0.7; } - 70% { transform: scale(1.9); opacity: 0; } - 100% { transform: scale(1.9); opacity: 0; } - } - @keyframes msgIn { - from { opacity: 0; transform: translateY(5px); } - to { opacity: 1; transform: translateY(0); } - } + @keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } + @keyframes pulse { 0% { transform: scale(1); opacity: 0.7; } 70% { transform: scale(1.9); opacity: 0; } 100% { transform: scale(1.9); opacity: 0; } } + @keyframes msgIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } } + @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } @@ -869,8 +620,7 @@ const HTML = `
+ oninput="App.formatCode(this)" onkeydown="if(event.key==='Enter')App.joinSession()">
@@ -893,42 +643,100 @@ const HTML = `
drop files to send
+
${NAME}
+ +
+ + + +
+
+ 1:00:00
+ +
-
-
- sending… -
- 0% - + + +
+
connection info
+
my ip—
+
peer ip—
+
rtt—
-
-
- -
- - - + + +
+
+
+ sending… +
+ 0% + +
+
+
+ +
+ + + + +
+ + +
+
+ + files vanish when the session ends +
+
+
no shared files yet · click "+ new file" to create one
+
+
+ + +
+
+
+ + → + + : + + +
+
server opens a TCP listener on local port and proxies connections to the target host:port
+
+
+
no active forwards \xb7 add one above
+
+
@@ -942,29 +750,27 @@ const HTML = ` `; -- 2.39.5