From bee67543ddd6937b44cdec3e166735b746ad754f Mon Sep 17 00:00:00 2001 From: Cloyir Date: Tue, 7 Jul 2026 08:36:01 +0100 Subject: [PATCH] Initial commit --- .gitignore | 12 + README.md | 132 +++++++++++ deno.json | 11 + deno.lock | 3 + public/host.html | 33 +++ public/host.js | 168 ++++++++++++++ public/styles.css | 182 +++++++++++++++ public/viewer.html | 37 +++ public/viewer.js | 209 +++++++++++++++++ src/main.ts | 564 +++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 1351 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 public/host.html create mode 100644 public/host.js create mode 100644 public/styles.css create mode 100644 public/viewer.html create mode 100644 public/viewer.js create mode 100644 src/main.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6375d3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Local Deno cache +.deno/ + +# Node cache, only needed if you use Node-based local tooling +node_modules/ + +# Runtime output +media/ +*.log + +# Build output +dist/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8df98e --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# Deno Desktop Stream + +一个简易的局域网桌面分享命令行应用。主机在浏览器选择桌面、窗口或应用进行分享,客机通过主机端口打开观看页面。 + +当前传输链路使用 WebRTC,服务端只负责页面托管和 WebSocket 信令转发。 + +## 功能 + +- 浏览器原生屏幕/窗口/应用采集 +- 可选系统音频或标签页音频,取决于浏览器和被分享内容 +- 局域网低延迟观看 +- 主机页面和观看页面由同一个服务提供 +- 可打包为单个 Windows exe + +## 依赖 + +- Deno 2.x + +Deno 仅用于开发、检查和打包。打包后的 exe 不需要安装 Deno、Node.js、npm、FFmpeg 或 node-media-server。 + +观看和分享需要现代浏览器,建议使用 Chrome 或 Edge。 + +## 开发运行 + +```powershell +deno task start +``` + +默认监听 `8080`: + +- 主机页面:`http://localhost:8080/` +- 观看页面:`http://localhost:8080/viewer` + +自定义端口: + +```powershell +deno run -A src/main.ts --port 9090 +``` + +检查代码: + +```powershell +deno task check +``` + +## 打包 exe + +```powershell +deno task compile +``` + +输出文件: + +```text +dist/media-server.exe +``` + +打包时会把 `public/` 页面资源嵌入 exe。分发给别人时只需要这个 exe 文件。 + +运行 exe: + +```powershell +.\dist\media-server.exe +``` + +指定端口: + +```powershell +.\dist\media-server.exe --port 8081 +``` + +## 使用方式 + +1. 主机运行服务。 +2. 主机打开 `http://localhost:8080/`,点击“选择桌面或应用”。 +3. 客机在同一局域网访问 `http://<主机IP>:8080/viewer`。 +4. 如果 Windows 防火墙弹窗,允许当前网络访问。 + +主机分享页建议从 `localhost` 打开。浏览器只允许在安全上下文中调用屏幕分享,`localhost` 属于浏览器允许的安全上下文。客机观看页不需要调用屏幕分享,可以用局域网 IP 访问。 + +## 常见问题 + +### 端口被占用 + +如果启动时看到: + +```text +Port 8080 is already in use. +``` + +说明当前端口已经被其他程序占用。可以换端口启动: + +```powershell +.\dist\media-server.exe --port 8081 +``` + +也可以关闭占用端口的进程后再启动。 + +### 客机打不开页面 + +- 确认主机和客机在同一局域网。 +- 确认访问的是主机局域网 IP,不是 `localhost`。 +- 确认 Windows 防火墙允许该程序访问当前网络。 +- 确认端口没有被路由器、杀毒软件或公司网络策略拦截。 + +### 跨公网访问 + +局域网内通常不需要额外服务。跨公网或复杂 NAT 时,WebRTC 可能需要 TURN 服务,本项目默认只配置了公共 STUN。 + +## 项目结构 + +```text +. +├── deno.json # Deno 任务和编译配置 +├── public/ # 浏览器页面和前端逻辑 +├── src/main.ts # HTTP 服务和 WebRTC 信令服务 +└── README.md +``` + +## Git 提交建议 + +建议提交源码和静态页面,不提交构建产物: + +```text +deno.json +README.md +.gitignore +src/ +public/ +``` + +`dist/`、`media/`、本地缓存和日志已经在 `.gitignore` 中忽略。 \ No newline at end of file diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..db34628 --- /dev/null +++ b/deno.json @@ -0,0 +1,11 @@ +{ + "tasks": { + "start": "deno run -A src/main.ts", + "check": "deno check src/main.ts", + "compile": "deno compile -A --include public --output dist/media-server.exe src/main.ts" + }, + "compilerOptions": { + "lib": ["deno.ns", "dom", "dom.iterable", "esnext"], + "strict": true + } +} \ No newline at end of file diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..c0a447b --- /dev/null +++ b/deno.lock @@ -0,0 +1,3 @@ +{ + "version": "5" +} diff --git a/public/host.html b/public/host.html new file mode 100644 index 0000000..26cad25 --- /dev/null +++ b/public/host.html @@ -0,0 +1,33 @@ + + + + + + 桌面分享 + + + +
+
+
+
+

Host

+

桌面分享

+
+ 未推流 +
+ + + +
+ + + 打开观看页 +
+ +

选择一个屏幕、窗口或浏览器标签页后,客机访问本机端口的 /viewer 即可观看。

+
+
+ + + \ No newline at end of file diff --git a/public/host.js b/public/host.js new file mode 100644 index 0000000..2aeab28 --- /dev/null +++ b/public/host.js @@ -0,0 +1,168 @@ +const startButton = document.querySelector("#start"); +const stopButton = document.querySelector("#stop"); +const preview = document.querySelector("#preview"); +const state = document.querySelector("#state"); +const message = document.querySelector("#message"); + +const peerConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }; + +let stream; +let signal; +const peers = new Map(); + +startButton.addEventListener("click", startSharing); +stopButton.addEventListener("click", stopSharing); + +async function startSharing() { + console.info("[host] startSharing clicked"); + startButton.disabled = true; + setState("connecting", "准备中"); + message.textContent = "请选择要分享的桌面、窗口或应用。"; + + try { + stream = await navigator.mediaDevices.getDisplayMedia({ + video: { frameRate: 30 }, + audio: { + echoCancellation: false, + noiseSuppression: false, + sampleRate: 48000, + }, + }); + + console.info("[host] getDisplayMedia resolved", describeStream(stream)); + preview.srcObject = stream; + signal = openSignalSocket(); + + stream.getTracks().forEach((track) => { + track.addEventListener("ended", () => { + console.info("[host] track ended", describeTrack(track)); + stopSharing(); + }); + }); + + stopButton.disabled = false; + setState("live", "推流中"); + message.textContent = `观看地址:${location.origin}/viewer`; + } catch (error) { + console.error("[host] startSharing failed", error); + stopSharing(); + message.textContent = error instanceof Error ? error.message : "启动分享失败。"; + } +} + +function openSignalSocket() { + const protocol = location.protocol === "https:" ? "wss" : "ws"; + const nextSocket = new WebSocket(`${protocol}://${location.host}/signal?role=host`); + + nextSocket.addEventListener("open", () => console.info("[host] signal open")); + nextSocket.addEventListener("message", (event) => void handleSignalMessage(JSON.parse(event.data))); + nextSocket.addEventListener("close", (event) => console.info("[host] signal close", { code: event.code, reason: event.reason })); + nextSocket.addEventListener("error", (event) => console.error("[host] signal error", event)); + + return nextSocket; +} + +async function handleSignalMessage(data) { + console.info("[host] signal", data); + + if (data.type === "viewer-join" && typeof data.viewerId === "string") { + await createPeer(data.viewerId); + return; + } + + if (data.type === "answer" && typeof data.viewerId === "string") { + const peer = peers.get(data.viewerId); + if (peer) { + await peer.setRemoteDescription(data.sdp); + } + return; + } + + if (data.type === "candidate" && typeof data.viewerId === "string") { + const peer = peers.get(data.viewerId); + if (peer && data.candidate) { + await peer.addIceCandidate(data.candidate); + } + return; + } + + if (data.type === "viewer-left" && typeof data.viewerId === "string") { + closePeer(data.viewerId); + } +} + +async function createPeer(viewerId) { + closePeer(viewerId); + + const peer = new RTCPeerConnection(peerConfig); + peers.set(viewerId, peer); + + stream.getTracks().forEach((track) => peer.addTrack(track, stream)); + peer.addEventListener("icecandidate", (event) => { + if (event.candidate) { + sendSignal({ type: "candidate", to: viewerId, candidate: event.candidate }); + } + }); + peer.addEventListener("connectionstatechange", () => { + console.info("[host] peer state", { viewerId, state: peer.connectionState }); + if (["failed", "closed", "disconnected"].includes(peer.connectionState)) { + closePeer(viewerId); + } + }); + + const offer = await peer.createOffer(); + await peer.setLocalDescription(offer); + sendSignal({ type: "offer", to: viewerId, sdp: peer.localDescription }); +} + +function sendSignal(data) { + if (signal?.readyState === WebSocket.OPEN) { + signal.send(JSON.stringify(data)); + } +} + +function closePeer(viewerId) { + const peer = peers.get(viewerId); + if (peer) { + peer.close(); + peers.delete(viewerId); + } +} + +function stopSharing() { + console.info("[host] stopSharing", { peers: peers.size, signalState: signal?.readyState }); + for (const viewerId of peers.keys()) { + closePeer(viewerId); + } + signal?.close(); + stream?.getTracks().forEach((track) => track.stop()); + + signal = undefined; + stream = undefined; + preview.srcObject = null; + startButton.disabled = false; + stopButton.disabled = true; + setState("idle", "未推流"); +} + +function describeStream(nextStream) { + return { + tracks: nextStream.getTracks().map(describeTrack), + }; +} + +function describeTrack(track) { + return { + kind: track.kind, + label: track.label, + enabled: track.enabled, + muted: track.muted, + readyState: track.readyState, + settings: track.getSettings(), + }; +} + +function setState(kind, text) { + state.className = `badge ${kind}`; + state.textContent = text; +} diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..7027926 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,182 @@ +:root { + color-scheme: light; + font-family: "Segoe UI", "Microsoft YaHei", sans-serif; + background: #eef3f1; + color: #16211f; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + linear-gradient(135deg, rgba(23, 86, 77, 0.12), transparent 38%), + linear-gradient(315deg, rgba(222, 128, 74, 0.16), transparent 44%), + #eef3f1; +} + +.shell { + display: grid; + min-height: 100vh; + place-items: center; + padding: 32px; +} + +.panel { + width: min(1040px, 100%); + padding: 24px; + border: 1px solid rgba(28, 48, 44, 0.16); + border-radius: 8px; + background: rgba(255, 255, 255, 0.86); + box-shadow: 0 24px 70px rgba(41, 57, 52, 0.16); +} + +.title-row { + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.eyebrow { + margin: 0 0 6px; + color: #66736f; + font-size: 13px; + font-weight: 700; + text-transform: uppercase; +} + +h1 { + margin: 0; + font-size: clamp(28px, 5vw, 54px); + font-weight: 800; + letter-spacing: 0; +} + +.video-stage { + position: relative; +} + +video { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + border-radius: 6px; + background: #101817; + object-fit: contain; +} + +.play-overlay { + position: absolute; + left: 50%; + top: 50%; + min-width: 132px; + transform: translate(-50%, -50%); + box-shadow: 0 14px 36px rgba(0, 0, 0, 0.28); +} + +.play-overlay[hidden] { + display: none; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 18px; +} + +button, +.link-button { + min-height: 42px; + padding: 0 18px; + border: 1px solid #1f5f55; + border-radius: 6px; + background: #1f5f55; + color: #fff; + font: inherit; + font-weight: 700; + line-height: 42px; + text-decoration: none; + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.secondary { + border-color: rgba(31, 95, 85, 0.28); + background: #fff; + color: #1f5f55; +} + +.metric { + align-items: center; + display: inline-flex; + min-height: 42px; + padding: 0 12px; + border: 1px solid rgba(31, 95, 85, 0.2); + border-radius: 6px; + background: rgba(255, 255, 255, 0.72); + color: #40504c; + font-size: 14px; + font-weight: 700; +} + +.badge { + flex: 0 0 auto; + border-radius: 999px; + padding: 8px 12px; + font-size: 13px; + font-weight: 800; +} + +.idle { + background: #e5e9e7; + color: #576560; +} + +.connecting { + background: #fff2d7; + color: #8c5814; +} + +.live { + background: #dff4e8; + color: #17613b; +} + +.message { + margin: 14px 0 0; + color: #5f6c68; + line-height: 1.6; +} + +@media (max-width: 680px) { + .shell { + padding: 14px; + } + + .panel { + padding: 16px; + } + + .title-row { + align-items: stretch; + flex-direction: column; + } + + button, + .link-button, + .metric { + width: 100%; + text-align: center; + justify-content: center; + } +} \ No newline at end of file diff --git a/public/viewer.html b/public/viewer.html new file mode 100644 index 0000000..7430279 --- /dev/null +++ b/public/viewer.html @@ -0,0 +1,37 @@ + + + + + + 观看桌面流 + + + +
+
+
+
+

Viewer

+

观看桌面流

+
+ 等待直播 +
+ +
+ + +
+ +
+ + + + 延迟 -- ms +
+ +

如果画面未出现,请确认主机已经点击分享并允许浏览器采集屏幕。

+
+
+ + + \ No newline at end of file diff --git a/public/viewer.js b/public/viewer.js new file mode 100644 index 0000000..072146e --- /dev/null +++ b/public/viewer.js @@ -0,0 +1,209 @@ +const player = document.querySelector("#player"); +const playButton = document.querySelector("#play"); +const audioButton = document.querySelector("#audio"); +const reloadButton = document.querySelector("#reload"); +const goLiveButton = document.querySelector("#go-live"); +const state = document.querySelector("#state"); +const message = document.querySelector("#message"); +const latency = document.querySelector("#latency"); + +const peerConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }; + +let signal; +let peer; +let viewerId = crypto.randomUUID(); +let retryTimer; +let playPromise; +let remoteStream; + +player.muted = true; +player.defaultMuted = true; + +audioButton.disabled = true; +audioButton.textContent = "等待声音"; + +playButton.addEventListener("click", () => void playWhenReady()); +audioButton.addEventListener("click", () => { + player.muted = !player.muted; + audioButton.textContent = player.muted ? "开启声音" : "关闭声音"; + void playWhenReady(); +}); +reloadButton.addEventListener("click", connect); +goLiveButton.addEventListener("click", () => void playWhenReady()); + +player.addEventListener("playing", () => { + console.info("[viewer] media playing"); + setPlayButtonVisible(false); +}); +player.addEventListener("pause", () => { + console.info("[viewer] media pause"); + setPlayButtonVisible(true); +}); +player.addEventListener("waiting", () => console.info("[viewer] media waiting")); +player.addEventListener("error", () => { + console.error("[viewer] media error", player.error); + setPlayButtonVisible(true); +}); + +connect(); + +async function connect() { + console.info("[viewer] connect start"); + destroyConnection(); + setState("connecting", "连接中"); + latency.textContent = "WebRTC 连接中"; + message.textContent = "正在连接主机。"; + + const status = await fetch("/api/debug").then((response) => response.json()); + console.info("[viewer] status", status); + if (!status.hasHostSignal) { + setState("idle", "等待直播"); + latency.textContent = "等待主机"; + message.textContent = "主机还没有开始分享。"; + retryTimer = window.setTimeout(connect, 1000); + return; + } + + signal = openSignalSocket(); +} + +function openSignalSocket() { + const protocol = location.protocol === "https:" ? "wss" : "ws"; + const nextSocket = new WebSocket(`${protocol}://${location.host}/signal?role=viewer&viewerId=${viewerId}`); + + nextSocket.addEventListener("open", () => console.info("[viewer] signal open")); + nextSocket.addEventListener("message", (event) => void handleSignalMessage(JSON.parse(event.data))); + nextSocket.addEventListener("close", (event) => { + console.info("[viewer] signal close", { code: event.code, reason: event.reason }); + setState("idle", "直播断开"); + latency.textContent = "连接断开"; + retryTimer = window.setTimeout(connect, 1000); + }); + nextSocket.addEventListener("error", (event) => { + console.error("[viewer] signal error", event); + setState("idle", "连接错误"); + }); + + return nextSocket; +} + +async function handleSignalMessage(data) { + console.info("[viewer] signal", data); + + if (data.type === "ready" && typeof data.viewerId === "string") { + viewerId = data.viewerId; + return; + } + + if (data.type === "host-left") { + setState("idle", "直播结束"); + latency.textContent = "主机已断开"; + message.textContent = "主机已停止分享。"; + destroyPeer(); + return; + } + + if (data.type === "offer" && data.sdp) { + await acceptOffer(data.sdp); + return; + } + + if (data.type === "candidate" && data.candidate && peer) { + await peer.addIceCandidate(data.candidate); + } +} + +async function acceptOffer(offer) { + destroyPeer(); + peer = new RTCPeerConnection(peerConfig); + remoteStream = new MediaStream(); + player.srcObject = remoteStream; + + peer.addEventListener("track", (event) => { + console.info("[viewer] track", { kind: event.track.kind, label: event.track.label }); + remoteStream.addTrack(event.track); + if (event.track.kind === "audio") { + audioButton.disabled = false; + audioButton.textContent = player.muted ? "开启声音" : "关闭声音"; + } + void playWhenReady(); + }); + peer.addEventListener("icecandidate", (event) => { + if (event.candidate) { + sendSignal({ type: "candidate", candidate: event.candidate }); + } + }); + peer.addEventListener("connectionstatechange", () => { + console.info("[viewer] peer state", peer.connectionState); + latency.textContent = `WebRTC ${peer.connectionState}`; + if (peer.connectionState === "connected") { + setState("live", "播放中"); + message.textContent = "正在播放主机分享的桌面。"; + } + if (["failed", "closed", "disconnected"].includes(peer.connectionState)) { + setState("idle", "直播断开"); + } + }); + + await peer.setRemoteDescription(offer); + const answer = await peer.createAnswer(); + await peer.setLocalDescription(answer); + sendSignal({ type: "answer", sdp: peer.localDescription }); +} + +function sendSignal(data) { + if (signal?.readyState === WebSocket.OPEN) { + signal.send(JSON.stringify(data)); + } +} + +async function playWhenReady() { + if (playPromise) { + return playPromise; + } + + try { + console.info("[viewer] calling player.play", { readyState: player.readyState, muted: player.muted }); + playPromise = player.play(); + await playPromise; + setPlayButtonVisible(false); + } catch (error) { + console.warn("[viewer] player.play rejected", error); + message.textContent = "浏览器阻止了自动播放,请点击画面上的播放直播。"; + setPlayButtonVisible(true); + } finally { + playPromise = undefined; + } +} + +function destroyConnection() { + console.info("[viewer] destroyConnection"); + window.clearTimeout(retryTimer); + retryTimer = undefined; + signal?.close(); + signal = undefined; + destroyPeer(); + setPlayButtonVisible(false); +} + +function destroyPeer() { + peer?.close(); + peer = undefined; + remoteStream?.getTracks().forEach((track) => track.stop()); + remoteStream = undefined; + playPromise = undefined; + player.removeAttribute("src"); + player.srcObject = null; + player.load(); + audioButton.disabled = true; + audioButton.textContent = "等待声音"; +} + +function setPlayButtonVisible(visible) { + playButton.hidden = !visible; +} + +function setState(kind, text) { + state.className = `badge ${kind}`; + state.textContent = text; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..29856d5 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,564 @@ +import { existsSync, readdirSync } from "node:fs"; +import { dirname, isAbsolute, join, normalize, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +type CliOptions = { + webPort: number; + rtmpPort: number; + nmsHttpPort: number; + streamKey: string; + ffmpegPath: string; +}; + +type StreamSession = { + startedAt: number; + chunks: number; + bytes: number; + lastChunkSentAt?: number; + lastChunkReceivedAt?: number; + mimeType?: string; + initChunks: RelayChunk[]; + recentChunks: RelayChunk[]; + nextChunkId: number; +}; + +type RelayChunk = { + id: number; + data: Uint8Array; +}; + +const maxRecentChunks = 60; + +const rootDir = normalize(join(dirname(fileURLToPath(import.meta.url)), "..")); +const publicDir = join(rootDir, "public"); +const mediaRoot = join(rootDir, "media"); + +const mimeTypes: Record = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".m3u8": "application/vnd.apple.mpegurl", + ".ts": "video/mp2t", + ".ico": "image/x-icon", +}; + +const options = parseArgs(Deno.args); +clearStreamFiles(options.streamKey); + +let currentSession: StreamSession | undefined; +const viewers = new Set(); +let hostSignal: WebSocket | undefined; +const signalViewers = new Map(); + +try { + Deno.serve({ hostname: "0.0.0.0", port: options.webPort }, handleRequest); +} catch (error) { + if (error instanceof Deno.errors.AddrInUse) { + console.error(`Port ${options.webPort} is already in use.`); + console.error(`Try another port: media-server.exe --port ${options.webPort + 1}`); + console.error(`Or close the process currently using port ${options.webPort}.`); + Deno.exit(1); + } + + throw error; +} + +async function handleRequest(request: Request) { + const url = new URL(request.url); + + if (url.pathname === "/ingest") { + return handleIngest(request, options); + } + + if (url.pathname === "/watch") { + return handleWatch(request); + } + + if (url.pathname === "/signal") { + return handleSignal(request, url); + } + + if (url.pathname === "/api/status") { + return jsonResponse({ + publishing: currentSession !== undefined, + hlsPath: `/hls/live/${options.streamKey}/index.m3u8`, + streamKey: options.streamKey, + }); + } + + if (url.pathname === "/api/debug") { + return jsonResponse(getDebugState(options)); + } + + if (url.pathname.startsWith("/hls/")) { + return serveFile(mediaRoot, url.pathname.replace(/^\/hls\//, "")); + } + + if (url.pathname === "/") { + return serveFile(publicDir, "host.html"); + } + + if (url.pathname === "/viewer") { + return serveFile(publicDir, "viewer.html"); + } + + return serveFile(publicDir, url.pathname.slice(1)); +} + +console.log(`Host page: http://localhost:${options.webPort}/`); +console.log(`Viewer page: http://localhost:${options.webPort}/viewer`); +console.log(`LAN access: http://:${options.webPort}/viewer`); +console.log("Stream mode: WebRTC with WebSocket signaling"); + +function handleSignal(request: Request, url: URL): Response { + if (request.headers.get("upgrade") !== "websocket") { + return new Response("Expected websocket", { status: 426 }); + } + + const role = url.searchParams.get("role"); + const viewerId = url.searchParams.get("viewerId") ?? crypto.randomUUID(); + const { socket, response } = Deno.upgradeWebSocket(request); + + socket.onopen = () => { + if (role === "host") { + hostSignal = socket; + console.log("[signal] host connected"); + socket.send(JSON.stringify({ type: "ready" })); + for (const id of signalViewers.keys()) { + socket.send(JSON.stringify({ type: "viewer-join", viewerId: id })); + } + return; + } + + signalViewers.set(viewerId, socket); + console.log(`[signal] viewer connected id=${viewerId} count=${signalViewers.size}`); + socket.send(JSON.stringify({ type: "ready", viewerId })); + hostSignal?.send(JSON.stringify({ type: "viewer-join", viewerId })); + }; + + socket.onmessage = (event) => { + if (typeof event.data !== "string") { + return; + } + + const message = parseJsonMessage(event.data); + if (!message?.type) { + return; + } + + if (role === "host") { + const target = typeof message.to === "string" ? signalViewers.get(message.to) : undefined; + target?.send(JSON.stringify(message)); + return; + } + + hostSignal?.send(JSON.stringify({ ...message, viewerId })); + }; + + socket.onclose = () => { + if (role === "host") { + if (hostSignal === socket) { + hostSignal = undefined; + } + console.log("[signal] host disconnected"); + broadcastSignalViewers({ type: "host-left" }); + return; + } + + signalViewers.delete(viewerId); + console.log(`[signal] viewer disconnected id=${viewerId} count=${signalViewers.size}`); + hostSignal?.send(JSON.stringify({ type: "viewer-left", viewerId })); + }; + + socket.onerror = () => { + socket.close(); + }; + + return response; +} + +function broadcastSignalViewers(message: unknown): void { + const text = JSON.stringify(message); + for (const viewer of signalViewers.values()) { + if (viewer.readyState === WebSocket.OPEN) { + viewer.send(text); + } + } +} + +async function handleIngest(request: Request, cliOptions: CliOptions): Promise { + if (request.headers.get("upgrade") !== "websocket") { + return new Response("Expected websocket", { status: 426 }); + } + + if (currentSession) { + return new Response("Another host is already publishing", { status: 409 }); + } + + const { socket, response } = Deno.upgradeWebSocket(request); + let session: StreamSession | undefined; + + socket.binaryType = "arraybuffer"; + socket.onopen = () => { + console.log("[ingest] websocket opened"); + session = { + startedAt: Date.now(), + chunks: 0, + bytes: 0, + initChunks: [], + recentChunks: [], + nextChunkId: 1, + }; + currentSession = session; + socket.send(JSON.stringify({ type: "ready" })); + }; + socket.onmessage = async (event) => { + if (typeof event.data === "string" && session) { + const message = parseJsonMessage(event.data); + if (message?.type === "start" && typeof message.mimeType === "string") { + session.mimeType = message.mimeType; + console.log(`[relay] stream mime=${session.mimeType}`); + broadcastJson({ type: "stream", mimeType: session.mimeType }); + } + return; + } + + if (!(event.data instanceof ArrayBuffer) || !session) { + return; + } + + const framedChunk = new Uint8Array(event.data); + const sentAt = readChunkTimestamp(framedChunk); + const chunk = framedChunk.slice(8); + if (chunk.byteLength < 256) { + console.log(`[relay] skipped tiny chunk bytes=${chunk.byteLength}`); + return; + } + + session.chunks += 1; + session.bytes += chunk.byteLength; + session.lastChunkSentAt = sentAt; + session.lastChunkReceivedAt = Date.now(); + const relayChunk = { id: session.nextChunkId, data: framedChunk }; + session.nextChunkId += 1; + if (session.initChunks.length < 1) { + session.initChunks.push(relayChunk); + } + session.recentChunks.push(relayChunk); + while (session.recentChunks.length > maxRecentChunks) { + session.recentChunks.shift(); + } + if (session.chunks === 1 || session.chunks % 40 === 0) { + console.log(`[relay] chunk=${session.chunks} bytes=${chunk.byteLength} total=${session.bytes} viewers=${viewers.size}`); + } + broadcastBinary(framedChunk); + }; + socket.onclose = (event) => { + console.log(`[ingest] websocket closed code=${event.code} reason=${event.reason || ""}`); + if (currentSession === session) { + currentSession = undefined; + } + broadcastJson({ type: "end" }); + }; + socket.onerror = (event) => { + console.error("[ingest] websocket error", event); + if (currentSession === session) { + currentSession = undefined; + } + broadcastJson({ type: "end" }); + }; + + return response; +} + +function handleWatch(request: Request): Response { + if (request.headers.get("upgrade") !== "websocket") { + return new Response("Expected websocket", { status: 426 }); + } + + const { socket, response } = Deno.upgradeWebSocket(request); + socket.binaryType = "arraybuffer"; + socket.onopen = () => { + viewers.add(socket); + console.log(`[watch] viewer connected count=${viewers.size}`); + if (currentSession?.mimeType) { + socket.send(JSON.stringify({ type: "stream", mimeType: currentSession.mimeType })); + const sentIds = new Set(); + for (const chunk of currentSession.initChunks) { + socket.send(toArrayBuffer(chunk.data)); + sentIds.add(chunk.id); + } + for (const chunk of currentSession.recentChunks) { + if (!sentIds.has(chunk.id)) { + socket.send(toArrayBuffer(chunk.data)); + } + } + } else { + socket.send(JSON.stringify({ type: "waiting" })); + } + }; + socket.onclose = () => { + viewers.delete(socket); + console.log(`[watch] viewer disconnected count=${viewers.size}`); + }; + socket.onerror = () => { + viewers.delete(socket); + console.log(`[watch] viewer error count=${viewers.size}`); + }; + + return response; +} + +function clearStreamFiles(streamKey: string): void { + const streamDir = join(mediaRoot, "live", streamKey); + + try { + Deno.removeSync(streamDir, { recursive: true }); + console.log(`[hls] cleared ${streamDir}`); + } catch { + // Old HLS files are optional; the directory may not exist on first run. + } +} + +function getDebugState(cliOptions: CliOptions): Record { + const streamDir = join(mediaRoot, "live", cliOptions.streamKey); + const hlsPath = join(streamDir, "index.m3u8"); + + return { + publishing: currentSession !== undefined, + viewers: viewers.size, + signalViewers: signalViewers.size, + hasHostSignal: hostSignal?.readyState === WebSocket.OPEN, + mode: "webrtc-signaling", + session: currentSession + ? { + chunks: currentSession.chunks, + bytes: currentSession.bytes, + ageMs: Date.now() - currentSession.startedAt, + mimeType: currentSession.mimeType, + bufferedChunks: currentSession.recentChunks.length, + lastChunkSentAt: currentSession.lastChunkSentAt, + lastChunkReceivedAt: currentSession.lastChunkReceivedAt, + lastRelayDelayMs: currentSession.lastChunkSentAt && currentSession.lastChunkReceivedAt + ? currentSession.lastChunkReceivedAt - currentSession.lastChunkSentAt + : undefined, + } + : undefined, + ffmpegPath: cliOptions.ffmpegPath, + hlsPath, + files: listFiles(streamDir), + manifest: readTextFile(hlsPath), + }; +} + +function listFiles(dir: string): Array> { + if (!existsSync(dir)) { + return []; + } + + return readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => { + const path = join(dir, entry.name); + const stat = Deno.statSync(path); + return { + name: entry.name, + size: stat.size, + modified: stat.mtime?.toISOString(), + }; + }); +} + +function readTextFile(path: string): string | undefined { + try { + return new TextDecoder().decode(Deno.readFileSync(path)); + } catch { + return undefined; + } +} + +function logHlsState(hlsPath: string): void { + const dir = dirname(hlsPath); + const files = listFiles(dir).map((file) => `${String(file.name)}:${String(file.size)}`).join(", "); + console.log(`[hls] files=${files || ""}`); +} + +async function logReadableStream(prefix: string, stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + const text = decoder.decode(value, { stream: true }).trim(); + if (text) { + for (const line of text.split(/\r?\n/)) { + console.log(`[${prefix}] ${line}`); + } + } + } + } catch (error) { + console.error(`[${prefix}] failed to read stderr`, error); + } +} + +async function serveFile(baseDir: string, relativePath: string): Promise { + const safeRelativePath = relativePath === "" ? "host.html" : relativePath; + const filePath = normalize(join(baseDir, safeRelativePath)); + const normalizedBase = normalize(baseDir); + const relativeToBase = relative(normalizedBase, filePath); + + if (relativeToBase.startsWith("..") || isAbsolute(relativeToBase) || !existsSync(filePath)) { + return new Response("Not found", { status: 404 }); + } + + const body = await Deno.readFile(filePath); + return new Response(body, { + headers: { + "content-type": mimeTypes[getExtension(filePath)] ?? "application/octet-stream", + "cache-control": filePath.endsWith(".m3u8") ? "no-cache" : "public, max-age=2", + }, + }); +} + +function parseArgs(args: string[]): CliOptions { + const values = new Map(); + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg.startsWith("--")) { + continue; + } + + const [rawKey, inlineValue] = arg.slice(2).split("=", 2); + const value = inlineValue ?? args[index + 1]; + if (inlineValue === undefined) { + index += 1; + } + values.set(rawKey, value); + } + + return { + webPort: readPort(values.get("port"), 8080, "port"), + rtmpPort: readPort(values.get("rtmp-port"), 1935, "rtmp-port"), + nmsHttpPort: readPort(values.get("nms-http-port"), 8001, "nms-http-port"), + streamKey: values.get("stream-key") ?? "desktop", + ffmpegPath: values.get("ffmpeg") ?? "ffmpeg", + }; +} + +function readPort(value: string | undefined, fallback: number, name: string): number { + const port = value === undefined ? fallback : Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`--${name} 必须是 1 到 65535 之间的端口号`); + } + return port; +} + +async function assertCommand(command: string, args: string[], message: string): Promise { + try { + const status = await new Deno.Command(command, { args, stdout: "null", stderr: "null" }).spawn().status; + if (!status.success) { + throw new Error(message); + } + } catch { + throw new Error(message); + } +} + +async function resolveFfmpegPath(candidate: string): Promise { + if (candidate !== "ffmpeg" || await commandExists(candidate)) { + return candidate; + } + + const localAppData = Deno.env.get("LOCALAPPDATA"); + if (!localAppData) { + return candidate; + } + + const wingetPackages = join(localAppData, "Microsoft", "WinGet", "Packages"); + return findFile(wingetPackages, "ffmpeg.exe", 6) ?? candidate; +} + +async function commandExists(command: string): Promise { + try { + const status = await new Deno.Command(command, { args: ["-version"], stdout: "null", stderr: "null" }).spawn().status; + return status.success; + } catch { + return false; + } +} + +function findFile(root: string, filename: string, maxDepth: number): string | undefined { + if (maxDepth < 0 || !existsSync(root)) { + return undefined; + } + + for (const entry of readdirSync(root, { withFileTypes: true })) { + const entryPath = join(root, entry.name); + if (entry.isFile() && entry.name.toLowerCase() === filename.toLowerCase()) { + return entryPath; + } + if (entry.isDirectory()) { + const found = findFile(entryPath, filename, maxDepth - 1); + if (found) { + return found; + } + } + } + + return undefined; +} + +function jsonResponse(data: unknown): Response { + return new Response(JSON.stringify(data), { + headers: { "content-type": "application/json; charset=utf-8" }, + }); +} + +function getExtension(path: string): string { + const index = path.lastIndexOf("."); + return index === -1 ? "" : path.slice(index); +} + +function broadcastJson(data: unknown): void { + const text = JSON.stringify(data); + for (const viewer of viewers) { + if (viewer.readyState === WebSocket.OPEN) { + viewer.send(text); + } + } +} + +function broadcastBinary(chunk: Uint8Array): void { + const payload = toArrayBuffer(chunk); + for (const viewer of viewers) { + if (viewer.readyState === WebSocket.OPEN) { + viewer.send(payload); + } + } +} + +function readChunkTimestamp(chunk: Uint8Array): number | undefined { + if (chunk.byteLength < 8) { + return undefined; + } + + return Number(new DataView(chunk.buffer, chunk.byteOffset, chunk.byteLength).getBigUint64(0, true)); +} + +function toArrayBuffer(chunk: Uint8Array): ArrayBuffer { + return chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength) as ArrayBuffer; +} + +function parseJsonMessage(text: string): Record | undefined { + try { + const value = JSON.parse(text); + return typeof value === "object" && value !== null ? value as Record : undefined; + } catch { + return undefined; + } +} \ No newline at end of file