From 395b505163d1345415f0840ceb03c3742b9e3d4b Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:17:13 +0000 Subject: [PATCH 01/30] Implement native external streamer MVP and integrate into Electron main process --- opennow-stable/package.json | 9 +- .../scripts/bundle-native-runtime.mjs | 29 + opennow-stable/src/main/index.ts | 37 +- .../src/main/services/streamerManager.ts | 290 ++ opennow-stable/src/main/settings.ts | 3 + opennow-stable/src/preload/index.ts | 15 + opennow-stable/src/renderer/src/App.tsx | 67 +- .../renderer/src/components/SettingsPage.tsx | 18 + opennow-stable/src/shared/gfn.ts | 18 + opennow-stable/src/shared/ipc.ts | 3 + opennow-streamer/Cargo.lock | 4382 +++++++++++++++++ opennow-streamer/Cargo.toml | 30 + opennow-streamer/README.md | 49 + opennow-streamer/src/control.rs | 58 + opennow-streamer/src/input.rs | 168 + opennow-streamer/src/main.rs | 56 + opennow-streamer/src/media.rs | 288 ++ opennow-streamer/src/messages.rs | 73 + opennow-streamer/src/sdp.rs | 184 + opennow-streamer/src/session.rs | 239 + opennow-streamer/src/window.rs | 227 + package.json | 3 +- 22 files changed, 6239 insertions(+), 7 deletions(-) create mode 100644 opennow-stable/scripts/bundle-native-runtime.mjs create mode 100644 opennow-stable/src/main/services/streamerManager.ts create mode 100644 opennow-streamer/Cargo.lock create mode 100644 opennow-streamer/Cargo.toml create mode 100644 opennow-streamer/README.md create mode 100644 opennow-streamer/src/control.rs create mode 100644 opennow-streamer/src/input.rs create mode 100644 opennow-streamer/src/main.rs create mode 100644 opennow-streamer/src/media.rs create mode 100644 opennow-streamer/src/messages.rs create mode 100644 opennow-streamer/src/sdp.rs create mode 100644 opennow-streamer/src/session.rs create mode 100644 opennow-streamer/src/window.rs diff --git a/opennow-stable/package.json b/opennow-stable/package.json index 65fcb59a..b3904090 100644 --- a/opennow-stable/package.json +++ b/opennow-stable/package.json @@ -16,7 +16,8 @@ "main": "dist-electron/main/index.js", "scripts": { "dev": "electron-vite dev", - "build": "electron-vite build", + "build:native-runtime": "cargo build --manifest-path ../opennow-streamer/Cargo.toml --release && node ./scripts/bundle-native-runtime.mjs", + "build": "npm run build:native-runtime && electron-vite build", "preview": "electron-vite preview", "dist": "npm run build && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder", "dist:signed": "npm run build && electron-builder", @@ -61,7 +62,11 @@ "files": [ "dist/**", "dist-electron/**", - "package.json" + "package.json", + "resources/bin/**" + ], + "extraResources": [ + { "from": "resources/bin", "to": "bin" } ], "asar": true, "win": { diff --git a/opennow-stable/scripts/bundle-native-runtime.mjs b/opennow-stable/scripts/bundle-native-runtime.mjs new file mode 100644 index 00000000..ecc60c75 --- /dev/null +++ b/opennow-stable/scripts/bundle-native-runtime.mjs @@ -0,0 +1,29 @@ +import { cpSync, existsSync, mkdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const repoRoot = resolve(__dirname, '../..'); +const outDir = resolve(repoRoot, 'opennow-stable/resources/bin'); +mkdirSync(outDir, { recursive: true }); + +const exeSuffix = process.platform === 'win32' ? '.exe' : ''; +const streamerCandidates = [ + resolve(repoRoot, `opennow-streamer/target/release/opennow-streamer${exeSuffix}`), + resolve(repoRoot, `opennow-streamer/target/debug/opennow-streamer${exeSuffix}`), +]; +const streamer = streamerCandidates.find((p) => existsSync(p)); +if (!streamer) { + throw new Error(`Missing opennow-streamer binary. Build it first: ${streamerCandidates.join(', ')}`); +} +cpSync(streamer, join(outDir, `opennow-streamer${exeSuffix}`)); + +const ffmpegEnv = process.env.OPENNOW_FFMPEG_BIN; +const ffmpeg = ffmpegEnv || execFileSync(process.platform === 'win32' ? 'where' : 'which', ['ffmpeg'], { encoding: 'utf8' }).split(/\r?\n/).find(Boolean); +if (!ffmpeg || !existsSync(ffmpeg)) { + throw new Error('Missing ffmpeg binary. Set OPENNOW_FFMPEG_BIN or ensure ffmpeg is on PATH.'); +} +cpSync(ffmpeg, join(outDir, `ffmpeg${exeSuffix}`)); +console.log(`Bundled native runtime into ${outDir}`); diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index b3c3e407..f79b5697 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -70,6 +70,7 @@ import { } from "./gfn/games"; import { fetchSubscription, fetchDynamicRegions } from "./gfn/subscription"; import { GfnSignalingClient } from "./gfn/signaling"; +import { StreamerManager } from "./services/streamerManager"; import { isSessionError, SessionError, GfnErrorCode } from "./gfn/errorCodes"; const __filename = fileURLToPath(import.meta.url); @@ -187,6 +188,7 @@ let signalingClient: GfnSignalingClient | null = null; let signalingClientKey: string | null = null; let authService: AuthService; let settingsManager: SettingsManager; +let streamerManager: StreamerManager; const SCREENSHOT_LIMIT = 60; function getScreenshotDirectory(): string { @@ -481,9 +483,16 @@ async function listRecordings(): Promise { } function emitToRenderer(event: MainToRendererSignalingEvent): void { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(IPC_CHANNELS.SIGNALING_EVENT, event); - } + void streamerManager?.forwardSignalingEvent(event).then((handled) => { + if (!handled && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.SIGNALING_EVENT, event); + } + }).catch((error) => { + console.error("[Streamer] Failed to forward signaling event:", error); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.SIGNALING_EVENT, event); + } + }); } async function createMainWindow(): Promise { @@ -769,6 +778,14 @@ function registerIpcHandlers(): void { return signalingClient.requestKeyframe(payload); }); + ipcMain.handle(IPC_CHANNELS.STREAMER_START, async (_event, payload) => { + await streamerManager.start(payload); + }); + + ipcMain.handle(IPC_CHANNELS.STREAMER_STOP, async (): Promise => { + await streamerManager.stop(); + }); + // Toggle fullscreen via IPC (for completeness) ipcMain.handle(IPC_CHANNELS.TOGGLE_FULLSCREEN, async () => { if (mainWindow && !mainWindow.isDestroyed()) { @@ -1153,6 +1170,19 @@ app.whenReady().then(async () => { await authService.initialize(); settingsManager = getSettingsManager(); + streamerManager = new StreamerManager( + () => mainWindow, + { + sendAnswer: async (payload) => { + if (!signalingClient) throw new Error("Signaling is not connected"); + await signalingClient.sendAnswer(payload); + }, + sendIceCandidate: async (payload) => { + if (!signalingClient) throw new Error("Signaling is not connected"); + await signalingClient.sendIceCandidate(payload); + }, + }, + ); // Request microphone permission on macOS at startup if (process.platform === "darwin") { @@ -1247,6 +1277,7 @@ app.on("window-all-closed", () => { app.on("before-quit", () => { refreshScheduler.stop(); + void streamerManager?.stop(); signalingClient?.disconnect(); signalingClient = null; signalingClientKey = null; diff --git a/opennow-stable/src/main/services/streamerManager.ts b/opennow-stable/src/main/services/streamerManager.ts new file mode 100644 index 00000000..1eb3f8f8 --- /dev/null +++ b/opennow-stable/src/main/services/streamerManager.ts @@ -0,0 +1,290 @@ +import { app, BrowserWindow } from "electron"; +import { createServer, type Server, type Socket } from "node:net"; +import { dirname, join, resolve } from "node:path"; +import { existsSync } from "node:fs"; +import { type ChildProcess, spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import type { + ExternalStreamerLaunchRequest, + IceCandidatePayload, + MainToRendererStreamerEvent, + MainToRendererSignalingEvent, + SendAnswerRequest, +} from "@shared/gfn"; +import { IPC_CHANNELS } from "@shared/ipc"; + +interface StreamerControlMessage { + type: string; + [key: string]: unknown; +} + +interface StreamerProcessMessage { + type: "hello" | "log" | "state" | "answer" | "local-ice"; + pid?: number; + level?: string; + message?: string; + state?: "idle" | "connecting" | "connected" | "disconnected" | "failed"; + detail?: string; + sdp?: string; + nvstSdp?: string; + candidate?: string; + sdpMid?: string | null; + sdpMLineIndex?: number | null; +} + +export class StreamerManager { + private server: Server | null = null; + private socket: Socket | null = null; + private process: ChildProcess | null = null; + private pendingReady: { resolve: () => void; reject: (error: Error) => void; timer: NodeJS.Timeout } | null = null; + private mode: "idle" | "legacy" | "external" = "idle"; + + constructor( + private readonly windowProvider: () => BrowserWindow | null, + private readonly signalingHandlers: { + sendAnswer: (payload: SendAnswerRequest) => Promise; + sendIceCandidate: (payload: IceCandidatePayload) => Promise; + }, + ) {} + + getAvailability(): { available: boolean; reason?: string } { + const binaryPath = this.resolveBinaryPath(); + if (!existsSync(binaryPath)) { + return { available: false, reason: `Missing opennow-streamer binary at ${binaryPath}` }; + } + return { available: true }; + } + + setLegacyMode(): void { + this.mode = "legacy"; + } + + async start(request: ExternalStreamerLaunchRequest): Promise { + const availability = this.getAvailability(); + if (!availability.available) { + throw new Error(availability.reason ?? "Native streamer binary unavailable"); + } + + await this.stop(); + const port = await this.createControlServer(); + const binaryPath = this.resolveBinaryPath(); + this.mode = "external"; + this.emit({ type: "availability", available: true }); + this.emit({ type: "state", state: "connecting", detail: "launching native streamer" }); + + const child = spawn(binaryPath, ["--control-url", `tcp://127.0.0.1:${port}`], { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); + this.process = child; + + child.stdout?.on("data", (chunk) => { + const message = chunk.toString("utf8").trim(); + if (message) this.emit({ type: "log", level: "stdout", message }); + }); + + child.stderr?.on("data", (chunk) => { + const message = chunk.toString("utf8").trim(); + if (message) this.emit({ type: "log", level: "stderr", message }); + }); + + child.once("exit", (code, signal) => { + this.emit({ + type: "state", + state: code === 0 ? "disconnected" : "failed", + detail: `native streamer exited code=${code ?? "null"} signal=${signal ?? "null"}`, + }); + this.cleanupSocket(); + this.process = null; + this.mode = "idle"; + }); + + await this.waitForReady(); + await this.sendControl({ type: "configure", session: request.session, settings: request.settings }); + } + + async stop(): Promise { + if (this.socket && !this.socket.destroyed) { + await this.sendControl({ type: "stop" }).catch(() => {}); + } + this.cleanupSocket(); + if (this.process) { + this.process.kill(); + this.process = null; + } + if (this.server) { + await new Promise((resolve) => this.server?.close(() => resolve())); + this.server = null; + } + this.mode = "idle"; + } + + async forwardSignalingEvent(event: MainToRendererSignalingEvent): Promise { + if (this.mode !== "external") { + return false; + } + + if (event.type === "offer") { + await this.sendControl({ type: "signaling-offer", sdp: event.sdp }); + return true; + } + + if (event.type === "remote-ice") { + await this.sendControl({ + type: "signaling-remote-ice", + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid ?? null, + sdpMLineIndex: event.candidate.sdpMLineIndex ?? null, + }); + return true; + } + + if (event.type === "disconnected") { + this.emit({ type: "state", state: "disconnected", detail: event.reason }); + return true; + } + + if (event.type === "error") { + this.emit({ type: "error", message: event.message }); + return true; + } + + return false; + } + + private resolveBinaryPath(): string { + const __filename = fileURLToPath(import.meta.url); + const mainDir = dirname(__filename); + const suffix = process.platform === "win32" ? ".exe" : ""; + const candidates = [ + resolve(mainDir, `../../../../opennow-streamer/target/release/opennow-streamer${suffix}`), + resolve(mainDir, `../../../../opennow-streamer/target/debug/opennow-streamer${suffix}`), + join(process.resourcesPath, "bin", `opennow-streamer${suffix}`), + resolve(app.getAppPath(), `../opennow-streamer/target/release/opennow-streamer${suffix}`), + ]; + return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]; + } + + private async createControlServer(): Promise { + if (this.server) { + await new Promise((resolve) => this.server?.close(() => resolve())); + this.server = null; + } + + const server = createServer(); + this.server = server; + server.on("connection", (socket) => { + this.socket = socket; + socket.setEncoding("utf8"); + let buffer = ""; + socket.on("data", (chunk: string) => { + buffer += chunk; + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (line) this.handleProcessMessage(line); + newline = buffer.indexOf("\n"); + } + }); + socket.on("close", () => { + this.socket = null; + }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to bind native streamer control socket"); + } + + return address.port; + } + + private waitForReady(): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingReady = null; + reject(new Error("Timed out waiting for native streamer handshake")); + }, 10_000); + this.pendingReady = { resolve, reject, timer }; + }); + } + + private cleanupSocket(): void { + if (this.pendingReady) { + clearTimeout(this.pendingReady.timer); + this.pendingReady = null; + } + this.socket?.destroy(); + this.socket = null; + } + + private async sendControl(message: StreamerControlMessage): Promise { + if (!this.socket || this.socket.destroyed) { + throw new Error("Native streamer control channel is not connected"); + } + await new Promise((resolve, reject) => { + this.socket?.write(`${JSON.stringify(message)}\n`, (error) => (error ? reject(error) : resolve())); + }); + } + + private handleProcessMessage(line: string): void { + let parsed: StreamerProcessMessage; + try { + parsed = JSON.parse(line) as StreamerProcessMessage; + } catch (error) { + this.emit({ type: "error", message: `Invalid native streamer payload: ${String(error)}` }); + return; + } + + if (parsed.type === "hello") { + if (this.pendingReady) { + clearTimeout(this.pendingReady.timer); + this.pendingReady.resolve(); + this.pendingReady = null; + } + this.emit({ type: "log", level: "info", message: `native streamer connected pid=${parsed.pid ?? "unknown"}` }); + return; + } + + if (parsed.type === "log") { + this.emit({ type: "log", level: parsed.level ?? "info", message: parsed.message ?? "" }); + return; + } + + if (parsed.type === "state") { + this.emit({ type: "state", state: parsed.state ?? "connecting", detail: parsed.detail }); + return; + } + + if (parsed.type === "answer" && parsed.sdp) { + void this.signalingHandlers.sendAnswer({ sdp: parsed.sdp, nvstSdp: parsed.nvstSdp }).catch((error) => { + this.emit({ type: "error", message: `Failed to forward native answer: ${String(error)}` }); + }); + return; + } + + if (parsed.type === "local-ice" && parsed.candidate) { + void this.signalingHandlers.sendIceCandidate({ + candidate: parsed.candidate, + sdpMid: parsed.sdpMid, + sdpMLineIndex: parsed.sdpMLineIndex, + }).catch((error) => { + this.emit({ type: "error", message: `Failed to forward native ICE: ${String(error)}` }); + }); + } + } + + private emit(event: MainToRendererStreamerEvent): void { + const window = this.windowProvider(); + if (window && !window.isDestroyed()) { + window.webContents.send(IPC_CHANNELS.STREAMER_EVENT, event); + } + } +} diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index 3f883a42..2805bc52 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -67,6 +67,8 @@ export interface Settings { gameLanguage: GameLanguage; /** Experimental request for Low Latency, Low Loss, Scalable throughput on new sessions */ enableL4S: boolean; + /** Experimental external native streamer process */ + enableExternalStreamer: boolean; } const defaultStopShortcut = "Ctrl+Shift+Q"; @@ -108,6 +110,7 @@ const DEFAULT_SETTINGS: Settings = { windowHeight: 900, gameLanguage: "en_US", enableL4S: false, + enableExternalStreamer: false, }; export class SettingsManager { diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index b856a476..029c7434 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -17,6 +17,8 @@ import type { SendAnswerRequest, IceCandidatePayload, KeyframeRequest, + ExternalStreamerLaunchRequest, + MainToRendererStreamerEvent, Settings, SubscriptionFetchRequest, StreamRegion, @@ -62,6 +64,9 @@ const api: OpenNowApi = { ipcRenderer.invoke(IPC_CHANNELS.SEND_ICE_CANDIDATE, input), requestKeyframe: (input: KeyframeRequest) => ipcRenderer.invoke(IPC_CHANNELS.REQUEST_KEYFRAME, input), + startExternalStreamer: (input: ExternalStreamerLaunchRequest) => + ipcRenderer.invoke(IPC_CHANNELS.STREAMER_START, input), + stopExternalStreamer: () => ipcRenderer.invoke(IPC_CHANNELS.STREAMER_STOP), onSignalingEvent: (listener: (event: MainToRendererSignalingEvent) => void) => { const wrapped = (_event: Electron.IpcRendererEvent, payload: MainToRendererSignalingEvent) => { listener(payload); @@ -72,6 +77,16 @@ const api: OpenNowApi = { ipcRenderer.off(IPC_CHANNELS.SIGNALING_EVENT, wrapped); }; }, + onStreamerEvent: (listener: (event: MainToRendererStreamerEvent) => void) => { + const wrapped = (_event: Electron.IpcRendererEvent, payload: MainToRendererStreamerEvent) => { + listener(payload); + }; + + ipcRenderer.on(IPC_CHANNELS.STREAMER_EVENT, wrapped); + return () => { + ipcRenderer.off(IPC_CHANNELS.STREAMER_EVENT, wrapped); + }; + }, onToggleFullscreen: (listener: () => void) => { const wrapped = () => listener(); ipcRenderer.on("app:toggle-fullscreen", wrapped); diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index b26e9ba0..26d351b4 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -9,6 +9,7 @@ import type { GameVariant, LoginProvider, MainToRendererSignalingEvent, + MainToRendererStreamerEvent, SessionInfo, Settings, SubscriptionInfo, @@ -70,6 +71,7 @@ const PLAYTIME_RESYNC_INTERVAL_MS = 5 * 60 * 1000; type GameSource = "main" | "library" | "public"; type AppPage = "home" | "library" | "settings"; type StreamStatus = "idle" | "queue" | "setup" | "starting" | "connecting" | "streaming"; +type StreamMode = "legacy" | "external"; type StreamLoadingStatus = "queue" | "setup" | "starting" | "connecting"; type ExitPromptState = { open: boolean; gameTitle: string }; type StreamWarningState = { @@ -426,6 +428,7 @@ export function App(): JSX.Element { windowHeight: 900, gameLanguage: "en_US", enableL4S: false, + enableExternalStreamer: false, }); const [settingsLoaded, setSettingsLoaded] = useState(false); const [regions, setRegions] = useState([]); @@ -644,6 +647,7 @@ export function App(): JSX.Element { const videoRef = useRef(null); const audioRef = useRef(null); const clientRef = useRef(null); + const streamModeRef = useRef("legacy"); const sessionRef = useRef(null); const hasInitializedRef = useRef(false); const regionsRequestRef = useRef(0); @@ -728,6 +732,10 @@ export function App(): JSX.Element { streamStatusRef.current = streamStatus; }, [streamStatus]); + useEffect(() => { + streamModeRef.current = settings.enableExternalStreamer ? "external" : "legacy"; + }, [settings.enableExternalStreamer]); + // Broadcast minimal session/loading state for UI overlays (controller + other listeners) useEffect(() => { const detail = { @@ -1301,6 +1309,32 @@ export function App(): JSX.Element { return () => unsubscribe(); }, [resetLaunchRuntime, settings]); + useEffect(() => { + const unsubscribe = window.openNow.onStreamerEvent((event: MainToRendererStreamerEvent) => { + if (event.type === "state") { + if (event.state === "connected") { + setLaunchError(null); + setStreamStatus("streaming"); + } else if (event.state === "failed") { + setLaunchError({ + stage: "connecting", + title: "Native streamer failed", + description: event.detail ?? "The external streamer process did not connect successfully.", + }); + resetLaunchRuntime({ keepLaunchError: true, keepStreamingContext: true }); + } else if (event.state === "disconnected" && streamModeRef.current === "external") { + resetLaunchRuntime(); + launchInFlightRef.current = false; + } + } else if (event.type === "error") { + console.error("Streamer error:", event.message); + } else if (event.type === "log") { + console.log(`[Streamer:${event.level}] ${event.message}`); + } + }); + return () => unsubscribe(); + }, [resetLaunchRuntime]); + // Save settings when changed const updateSetting = useCallback(async (key: K, value: Settings[K]) => { setSettings((prev) => ({ ...prev, [key]: value })); @@ -1531,6 +1565,20 @@ export function App(): JSX.Element { signalingServer: claimed.signalingServer, signalingUrl: claimed.signalingUrl, }); + if (settings.enableExternalStreamer) { + await window.openNow.startExternalStreamer({ + session: claimed, + settings: { + resolution: settings.resolution, + fps: settings.fps, + maxBitrateMbps: settings.maxBitrateMbps, + codec: settings.codec, + colorQuality: settings.colorQuality, + gameLanguage: settings.gameLanguage, + enableL4S: settings.enableL4S, + }, + }); + } }, [authSession, effectiveStreamingBaseUrl, findGameContextForSession, settings]); // Play game handler @@ -1747,12 +1795,27 @@ export function App(): JSX.Element { signalingServer: sessionToConnect.signalingServer, signalingUrl: sessionToConnect.signalingUrl, }); + if (settings.enableExternalStreamer) { + await window.openNow.startExternalStreamer({ + session: sessionToConnect, + settings: { + resolution: settings.resolution, + fps: settings.fps, + maxBitrateMbps: settings.maxBitrateMbps, + codec: settings.codec, + colorQuality: settings.colorQuality, + gameLanguage: settings.gameLanguage, + enableL4S: settings.enableL4S, + }, + }); + } } catch (error) { if (launchAbortRef.current) { return; } console.error("Launch failed:", error); setLaunchError(toLaunchErrorState(error, loadingStep)); + await window.openNow.stopExternalStreamer().catch(() => {}); await window.openNow.disconnectSignaling().catch(() => {}); clientRef.current?.dispose(); clientRef.current = null; @@ -1810,6 +1873,7 @@ export function App(): JSX.Element { } catch (error) { console.error("Navbar resume failed:", error); setLaunchError(toLaunchErrorState(error, loadingStep)); + await window.openNow.stopExternalStreamer().catch(() => {}); await window.openNow.disconnectSignaling().catch(() => {}); clientRef.current?.dispose(); clientRef.current = null; @@ -1838,6 +1902,7 @@ export function App(): JSX.Element { if (status !== "idle" && status !== "streaming") { launchAbortRef.current = true; } + await window.openNow.stopExternalStreamer().catch(() => {}); await window.openNow.disconnectSignaling(); const current = sessionRef.current; @@ -2136,7 +2201,7 @@ export function App(): JSX.Element { const loadingStatus = launchError ? launchError.stage : toLoadingStatus(streamStatus); return ( <> - {streamStatus !== "idle" && ( + {streamStatus !== "idle" && !settings.enableExternalStreamer && ( +
+ + +
+ {settings.controllerMode && (
diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index 66465e9a..605de22d 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -72,6 +72,8 @@ export interface Settings { gameLanguage: GameLanguage; /** Experimental request for Low Latency, Low Loss, Scalable throughput on new sessions */ enableL4S: boolean; + /** Experimental external native streamer process */ + enableExternalStreamer: boolean; } export interface LoginProvider { @@ -338,6 +340,19 @@ export interface KeyframeRequest { attempt: number; } +export interface ExternalStreamerLaunchRequest { + session: SessionInfo; + settings: StreamSettings; +} + +export type NativeStreamerState = "idle" | "connecting" | "connected" | "disconnected" | "failed"; + +export type MainToRendererStreamerEvent = + | { type: "availability"; available: boolean; reason?: string } + | { type: "state"; state: NativeStreamerState; detail?: string } + | { type: "log"; level: string; message: string } + | { type: "error"; message: string }; + export type MainToRendererSignalingEvent = | { type: "connected" } | { type: "disconnected"; reason: string } @@ -374,7 +389,10 @@ export interface OpenNowApi { sendAnswer(input: SendAnswerRequest): Promise; sendIceCandidate(input: IceCandidatePayload): Promise; requestKeyframe(input: KeyframeRequest): Promise; + startExternalStreamer(input: ExternalStreamerLaunchRequest): Promise; + stopExternalStreamer(): Promise; onSignalingEvent(listener: (event: MainToRendererSignalingEvent) => void): () => void; + onStreamerEvent(listener: (event: MainToRendererStreamerEvent) => void): () => void; /** Listen for F11 fullscreen toggle from main process */ onToggleFullscreen(listener: () => void): () => void; setFullscreen(v: boolean): Promise; diff --git a/opennow-stable/src/shared/ipc.ts b/opennow-stable/src/shared/ipc.ts index b83535e3..72420008 100644 --- a/opennow-stable/src/shared/ipc.ts +++ b/opennow-stable/src/shared/ipc.ts @@ -21,6 +21,9 @@ export const IPC_CHANNELS = { SEND_ANSWER: "gfn:send-answer", SEND_ICE_CANDIDATE: "gfn:send-ice-candidate", REQUEST_KEYFRAME: "gfn:request-keyframe", + STREAMER_START: "streamer:start", + STREAMER_STOP: "streamer:stop", + STREAMER_EVENT: "streamer:event", SIGNALING_EVENT: "gfn:signaling-event", TOGGLE_FULLSCREEN: "window:toggle-fullscreen", SET_FULLSCREEN: "window:set-fullscreen", diff --git a/opennow-streamer/Cargo.lock b/opennow-streamer/Cargo.lock new file mode 100644 index 00000000..fe5a2793 --- /dev/null +++ b/opennow-streamer/Cargo.lock @@ -0,0 +1,4382 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.11.0", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arc-swap" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "audiopus_sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" +dependencies = [ + "cmake", + "log", + "pkg-config", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytecheck" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.11.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dtls" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f016db07b91e9d79cc60a152c163d3f0ce2d4c0173cb3964de3526aab6e07fa" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bytecheck", + "byteorder", + "cbc", + "ccm", + "chacha20poly1305", + "der-parser", + "hmac", + "log", + "p256", + "p384", + "portable-atomic", + "rand", + "rand_core 0.6.4", + "rcgen", + "ring", + "rkyv", + "rustls", + "sec1", + "sha1", + "sha2", + "thiserror 1.0.69", + "tokio", + "webrtc-util", + "x25519-dalek", + "x509-parser", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gilrs" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fa85c2e35dc565c90511917897ea4eae16b77f2773d5223536f7b602536d462" +dependencies = [ + "fnv", + "gilrs-core", + "log", + "uuid", + "vec_map", +] + +[[package]] +name = "gilrs-core" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d23f2cc5144060a7f8d9e02d3fce5d06705376568256a509cdbc3c24d47e4f04" +dependencies = [ + "inotify", + "js-sys", + "libc", + "libudev-sys", + "log", + "nix 0.30.1", + "objc2-core-foundation", + "objc2-io-kit", + "uuid", + "vec_map", + "wasm-bindgen", + "web-sys", + "windows", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interceptor" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f73f4fdb971cab2d599cbdc2ccf0c6ea8fb27347b871ed14c65ce2353dbe75b" +dependencies = [ + "async-trait", + "bytes", + "futures", + "log", + "portable-atomic", + "rand", + "rtcp", + "rtp", + "thiserror 1.0.69", + "tokio", + "waitgroup", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "pin-utils", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2", + "objc2-contacts", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.0", + "block2", + "dispatch", + "libc", + "objc2", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "bitflags 2.11.0", + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "opennow-streamer" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "clap", + "env_logger", + "gilrs", + "log", + "opus", + "rtp", + "sdl2", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "url", + "webrtc", + "winit", +] + +[[package]] +name = "opus" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3809943dff6fbad5f0484449ea26bdb9cb7d8efdf26ed50d3c7f227f69eb5c" +dependencies = [ + "audiopus_sys", +] + +[[package]] +name = "orbclient" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59aed3b33578edcfa1bc96a321d590d31832b6ad55a26f0313362ce687e9abd6" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rancor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +dependencies = [ + "ptr_meta", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rend" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a30e631b7f4a03dee9056b8ef6982e8ba371dd5bedb74d3ec86df4499132c70" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown 0.16.1", + "indexmap", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8100bb34c0a1d0f907143db3149e6b4eea3c33b9ee8b189720168e818303986f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "rtcp" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb22f1cc99aea8152fdae6a4bc52a9caddf4bd1ff083d897c1f9f279956177e8" +dependencies = [ + "bytes", + "thiserror 1.0.69", + "webrtc-util", +] + +[[package]] +name = "rtp" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8d41f3565d9add11caabe7c61745517f4ef511c168a6aa2b59ce4c701802cde" +dependencies = [ + "bytes", + "memchr", + "portable-atomic", + "rand", + "serde", + "thiserror 1.0.69", + "webrtc-util", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "sdl2" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8356b2697d1ead5a34f40bcc3c5d3620205fe0c7be0a14656223bfeec0258891" +dependencies = [ + "bitflags 1.3.2", + "lazy_static", + "libc", + "sdl2-sys", +] + +[[package]] +name = "sdl2-sys" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26bcacfdd45d539fb5785049feb0038a63931aa896c7763a2a12e125ec58bd29" +dependencies = [ + "cfg-if", + "libc", + "version-compare", +] + +[[package]] +name = "sdp" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22c3b0257608d7de4de4c4ea650ccc2e6e3e45e3cd80039fcdee768bcb449253" +dependencies = [ + "rand", + "substring", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.11.0", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "stun" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e0fd33c04d4617df42c9c84c698511c59f59869629fb7a193067eec41bce347" +dependencies = [ + "base64", + "crc", + "lazy_static", + "md-5", + "rand", + "ring", + "subtle", + "thiserror 1.0.69", + "tokio", + "url", + "webrtc-util", +] + +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da053d28fe57e2c9d21b48261e14e7b4c8b670b54d2c684847b91feaf4c7dac5" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "turn" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a8b8ac3543b2a8eb0b28c7ac3d5f2db6221e057f3b3ae47cf7637b1333a5c3" +dependencies = [ + "async-trait", + "base64", + "futures", + "log", + "md-5", + "portable-atomic", + "rand", + "ring", + "stun", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "webrtc-util", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version-compare" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "waitgroup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1f50000a783467e6c0200f9d10642f4bc424e39efc1b770203e88b488f79292" +dependencies = [ + "atomic-waker", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.0", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.11.0", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webrtc" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ba06986c4fcfbbb4b490abe4b88887b0ac9de0d3eb0aae36f3254e38d6ecdd1" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "dtls", + "hex", + "interceptor", + "lazy_static", + "log", + "portable-atomic", + "rand", + "rcgen", + "regex", + "ring", + "rtcp", + "rtp", + "sdp", + "serde", + "serde_json", + "sha2", + "smol_str", + "stun", + "thiserror 1.0.69", + "tokio", + "turn", + "unicase", + "url", + "waitgroup", + "webrtc-data", + "webrtc-ice", + "webrtc-mdns", + "webrtc-media", + "webrtc-sctp", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "webrtc-data" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7ca42127ee64bcb71da36d151e6f87b12488c5f14c4f379e73d2d52a8e54aa0" +dependencies = [ + "bytes", + "log", + "portable-atomic", + "thiserror 1.0.69", + "tokio", + "webrtc-sctp", + "webrtc-util", +] + +[[package]] +name = "webrtc-ice" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ede72a36e5dda685814c389b2b34ac60b3ed000a81789e93626e27180eb785" +dependencies = [ + "arc-swap", + "async-trait", + "crc", + "log", + "portable-atomic", + "rand", + "serde", + "serde_json", + "stun", + "thiserror 1.0.69", + "tokio", + "turn", + "url", + "uuid", + "waitgroup", + "webrtc-mdns", + "webrtc-util", +] + +[[package]] +name = "webrtc-mdns" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b62bc8d3fb5024bc6ffde5f4aad2127ce17f8359dbc6f70208a324a12e44677" +dependencies = [ + "log", + "socket2 0.5.10", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-media" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9a28290bd0cdda196f8bf5c6f7dddaef18cc913ee0702cd1ea237bad203e337" +dependencies = [ + "byteorder", + "bytes", + "rand", + "rtp", + "thiserror 1.0.69", +] + +[[package]] +name = "webrtc-sctp" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea6633bf951b3fd71eba3244731c8d0814f6a80300620a4370cad2983a5a42f" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "crc", + "log", + "portable-atomic", + "rand", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-srtp" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e437f74b04f42049192e25cbb33c21c86be308875e6afe14cf8e28d1ffa35ac" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "byteorder", + "bytes", + "ctr", + "hmac", + "log", + "rtcp", + "rtp", + "sha1", + "subtle", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-util" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65c1e0143a43d40f69e1d8c2ffc8734e379b49c06d45892ea4104c388bf9ead" +dependencies = [ + "async-trait", + "bitflags 1.3.2", + "bytes", + "ipnet", + "lazy_static", + "log", + "nix 0.26.4", + "portable-atomic", + "rand", + "thiserror 1.0.69", + "tokio", + "winapi", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.11.0", + "block2", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.11.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/opennow-streamer/Cargo.toml b/opennow-streamer/Cargo.toml new file mode 100644 index 00000000..b3f73202 --- /dev/null +++ b/opennow-streamer/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "opennow-streamer" +version = "0.1.0" +edition = "2021" +description = "OpenNOW native streamer MVP" +license = "MIT" + +[dependencies] +anyhow = "1" +bytes = "1" +opus = "0.3" +sdl2 = "0.36" +clap = { version = "4", features = ["derive"] } +env_logger = "0.11" +gilrs = "0.11" +log = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +tokio-stream = "0.1" +url = "2" +webrtc = "0.17" +rtp = "0.17" +winit = "0.30" + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/opennow-streamer/README.md b/opennow-streamer/README.md new file mode 100644 index 00000000..54184195 --- /dev/null +++ b/opennow-streamer/README.md @@ -0,0 +1,49 @@ +# opennow-streamer + +`opennow-streamer` is the native external play-surface for OpenNOW. + +Why Rust: +- long-term maintainability with memory safety around RTP, input, windowing, and control IPC +- cross-platform native binary distribution without tying the streamer to Chromium +- async/runtime ecosystem that fits Electron main ↔ native control and WebRTC session ownership +- clear path to future decode/backend specialization without rewriting the app shell + +Why loopback socket IPC instead of stdio: +- framed full-duplex JSON works consistently across Windows, macOS, and Linux +- avoids stdio backpressure issues when the child is also producing logs +- gives Electron main a robust reconnect/health boundary for a long-lived streamer process +- leaves room for future stats/debug/control channels without redesigning the process contract + +Current MVP responsibilities implemented here: +- native control bridge to Electron main +- native WebRTC peer connection, offer handling, answer generation, and ICE forwarding +- GFN-specific SDP handling for server IP fixing, codec filtering, answer munging, NVST SDP generation, and manual media endpoint ICE injection +- native SDL2 play surface for decoded video +- native SDL2 audio output for decoded Opus audio +- native keyboard / mouse / controller capture using the existing GFN input packet semantics + +Current media implementation: +- video RTP is depacketized in-process and decoded through an FFmpeg child pipeline into RGB frames rendered in the SDL window +- audio RTP is depacketized in-process and decoded with libopus, then queued to SDL audio output +- the MVP decode path currently targets the practical GFN desktop path first: H.264 and H.265 video plus Opus audio + +Still intentionally out of scope for this phase: +- recording / screenshots migration +- microphone uplink migration +- AV1 native decode path +- HDR / 10-bit output polishing and platform-specific hardware decode optimization + +Project layout: +- `src/control.rs` — Electron/native control socket protocol +- `src/messages.rs` — typed control/state messages +- `src/sdp.rs` — GFN SDP and NVST helpers +- `src/session.rs` — peer connection + signaling/media orchestration +- `src/media.rs` — RTP depacketize + decode pipeline +- `src/input.rs` — GFN-compatible input packet encoding +- `src/window.rs` — SDL window, rendering, audio, and native input capture +- `src/main.rs` — process bootstrap + +Packaging/runtime model: +- packaged OpenNOW builds copy `opennow-streamer` and a colocated `ffmpeg` sidecar into `resources/bin/` via `opennow-stable/scripts/bundle-native-runtime.mjs` +- `opennow-stable` build now runs `cargo build --release` for the native streamer and bundles both binaries into Electron extra resources +- at runtime the streamer resolves `ffmpeg` relative to its own executable first, then `resources/bin`, then `OPENNOW_FFMPEG_BIN` for development overrides diff --git a/opennow-streamer/src/control.rs b/opennow-streamer/src/control.rs new file mode 100644 index 00000000..55a4568e --- /dev/null +++ b/opennow-streamer/src/control.rs @@ -0,0 +1,58 @@ +use anyhow::Context; +use serde::Serialize; +use tokio::{io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, net::TcpStream, sync::mpsc}; + +use crate::messages::{ControlMessage, StreamerMessage}; + +pub async fn connect(control_url: &str) -> anyhow::Result<(mpsc::Sender, mpsc::Receiver)> { + let addr = control_url.strip_prefix("tcp://").unwrap_or(control_url); + let stream = TcpStream::connect(addr).await.with_context(|| format!("failed to connect control socket {addr}"))?; + let (read_half, mut write_half) = stream.into_split(); + let (to_writer_tx, mut to_writer_rx) = mpsc::channel::(128); + let (from_reader_tx, from_reader_rx) = mpsc::channel::(128); + + tokio::spawn(async move { + while let Some(message) = to_writer_rx.recv().await { + let line = match serde_json::to_vec(&message) { + Ok(mut line) => { + line.push(b'\n'); + line + } + Err(_) => break, + }; + if write_half.write_all(&line).await.is_err() { + break; + } + } + }); + + tokio::spawn(async move { + let mut lines = BufReader::new(read_half).lines(); + loop { + match lines.next_line().await { + Ok(Some(line)) => { + if line.trim().is_empty() { + continue; + } + match serde_json::from_str::(&line) { + Ok(message) => { + if from_reader_tx.send(message).await.is_err() { + break; + } + } + Err(error) => { + log::error!("invalid control message: {error}"); + } + } + } + Ok(None) | Err(_) => break, + } + } + }); + + Ok((to_writer_tx, from_reader_rx)) +} + +pub async fn send(sender: &mpsc::Sender, message: T) { + let _ = sender.send(message).await; +} diff --git a/opennow-streamer/src/input.rs b/opennow-streamer/src/input.rs new file mode 100644 index 00000000..3cff9225 --- /dev/null +++ b/opennow-streamer/src/input.rs @@ -0,0 +1,168 @@ +use winit::{event::MouseButton, keyboard::KeyCode}; + +pub const INPUT_KEY_DOWN: u32 = 3; +pub const INPUT_KEY_UP: u32 = 4; +pub const INPUT_MOUSE_REL: u32 = 7; +pub const INPUT_MOUSE_BUTTON_DOWN: u32 = 8; +pub const INPUT_MOUSE_BUTTON_UP: u32 = 9; +pub const INPUT_GAMEPAD: u32 = 12; + +fn now_micros() -> u64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + now.as_micros() as u64 +} + +fn wrap_single_event(payload: &[u8]) -> Vec { + let mut wrapped = Vec::with_capacity(10 + payload.len()); + wrapped.push(0x23); + wrapped.extend_from_slice(&now_micros().to_be_bytes()); + wrapped.push(0x22); + wrapped.extend_from_slice(payload); + wrapped +} + +fn wrap_batched_event(payload: &[u8]) -> Vec { + let mut wrapped = Vec::with_capacity(12 + payload.len()); + wrapped.push(0x23); + wrapped.extend_from_slice(&now_micros().to_be_bytes()); + wrapped.push(0x21); + wrapped.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + wrapped.extend_from_slice(payload); + wrapped +} + +pub fn encode_key(key_code: u16, scancode: u16, modifiers: u16, down: bool) -> Vec { + let mut payload = vec![0_u8; 18]; + payload[0..4].copy_from_slice(&(if down { INPUT_KEY_DOWN } else { INPUT_KEY_UP }).to_le_bytes()); + payload[4..6].copy_from_slice(&key_code.to_be_bytes()); + payload[6..8].copy_from_slice(&modifiers.to_be_bytes()); + payload[8..10].copy_from_slice(&scancode.to_be_bytes()); + payload[10..18].copy_from_slice(&now_micros().to_be_bytes()); + wrap_single_event(&payload) +} + +pub fn encode_mouse_move(dx: i16, dy: i16) -> Vec { + let mut payload = vec![0_u8; 22]; + payload[0..4].copy_from_slice(&INPUT_MOUSE_REL.to_le_bytes()); + payload[4..6].copy_from_slice(&dx.to_be_bytes()); + payload[6..8].copy_from_slice(&dy.to_be_bytes()); + payload[14..22].copy_from_slice(&now_micros().to_be_bytes()); + wrap_batched_event(&payload) +} + +pub fn encode_mouse_button(button: u8, down: bool) -> Vec { + let mut payload = vec![0_u8; 18]; + payload[0..4].copy_from_slice(&(if down { INPUT_MOUSE_BUTTON_DOWN } else { INPUT_MOUSE_BUTTON_UP }).to_le_bytes()); + payload[4] = button; + payload[10..18].copy_from_slice(&now_micros().to_be_bytes()); + wrap_single_event(&payload) +} + +pub fn encode_gamepad(buttons: u16, left_trigger: u8, right_trigger: u8, left_x: i16, left_y: i16, right_x: i16, right_y: i16) -> Vec { + let mut payload = vec![0_u8; 38]; + payload[0..4].copy_from_slice(&INPUT_GAMEPAD.to_le_bytes()); + payload[4..6].copy_from_slice(&(26_u16).to_le_bytes()); + payload[10..12].copy_from_slice(&(20_u16).to_le_bytes()); + payload[12..14].copy_from_slice(&buttons.to_le_bytes()); + payload[14..16].copy_from_slice(&u16::from_le_bytes([left_trigger, right_trigger]).to_le_bytes()); + payload[16..18].copy_from_slice(&left_x.to_le_bytes()); + payload[18..20].copy_from_slice(&left_y.to_le_bytes()); + payload[20..22].copy_from_slice(&right_x.to_le_bytes()); + payload[22..24].copy_from_slice(&right_y.to_le_bytes()); + payload[26..28].copy_from_slice(&(85_u16).to_le_bytes()); + payload[30..38].copy_from_slice(&now_micros().to_le_bytes()); + wrap_batched_event(&payload) +} + +pub fn key_mapping(code: KeyCode) -> Option<(u16, u16)> { + Some(match code { + KeyCode::KeyA => (0x41, 0x04), + KeyCode::KeyB => (0x42, 0x05), + KeyCode::KeyC => (0x43, 0x06), + KeyCode::KeyD => (0x44, 0x07), + KeyCode::KeyE => (0x45, 0x08), + KeyCode::KeyF => (0x46, 0x09), + KeyCode::KeyG => (0x47, 0x0A), + KeyCode::KeyH => (0x48, 0x0B), + KeyCode::KeyI => (0x49, 0x0C), + KeyCode::KeyJ => (0x4A, 0x0D), + KeyCode::KeyK => (0x4B, 0x0E), + KeyCode::KeyL => (0x4C, 0x0F), + KeyCode::KeyM => (0x4D, 0x10), + KeyCode::KeyN => (0x4E, 0x11), + KeyCode::KeyO => (0x4F, 0x12), + KeyCode::KeyP => (0x50, 0x13), + KeyCode::KeyQ => (0x51, 0x14), + KeyCode::KeyR => (0x52, 0x15), + KeyCode::KeyS => (0x53, 0x16), + KeyCode::KeyT => (0x54, 0x17), + KeyCode::KeyU => (0x55, 0x18), + KeyCode::KeyV => (0x56, 0x19), + KeyCode::KeyW => (0x57, 0x1A), + KeyCode::KeyX => (0x58, 0x1B), + KeyCode::KeyY => (0x59, 0x1C), + KeyCode::KeyZ => (0x5A, 0x1D), + KeyCode::Digit0 => (0x30, 0x27), + KeyCode::Digit1 => (0x31, 0x1E), + KeyCode::Digit2 => (0x32, 0x1F), + KeyCode::Digit3 => (0x33, 0x20), + KeyCode::Digit4 => (0x34, 0x21), + KeyCode::Digit5 => (0x35, 0x22), + KeyCode::Digit6 => (0x36, 0x23), + KeyCode::Digit7 => (0x37, 0x24), + KeyCode::Digit8 => (0x38, 0x25), + KeyCode::Digit9 => (0x39, 0x26), + KeyCode::Enter => (0x0D, 0x28), + KeyCode::Escape => (0x1B, 0x29), + KeyCode::Backspace => (0x08, 0x2A), + KeyCode::Tab => (0x09, 0x2B), + KeyCode::Space => (0x20, 0x2C), + KeyCode::ArrowLeft => (0x25, 0x50), + KeyCode::ArrowRight => (0x27, 0x4F), + KeyCode::ArrowUp => (0x26, 0x52), + KeyCode::ArrowDown => (0x28, 0x51), + KeyCode::ShiftLeft => (0xA0, 0xE1), + KeyCode::ShiftRight => (0xA1, 0xE5), + KeyCode::ControlLeft => (0xA2, 0xE0), + KeyCode::ControlRight => (0xA3, 0xE4), + KeyCode::AltLeft => (0xA4, 0xE2), + KeyCode::AltRight => (0xA5, 0xE6), + KeyCode::SuperLeft => (0x5B, 0xE3), + KeyCode::SuperRight => (0x5C, 0xE7), + KeyCode::F1 => (0x70, 0x3A), + KeyCode::F2 => (0x71, 0x3B), + KeyCode::F3 => (0x72, 0x3C), + KeyCode::F4 => (0x73, 0x3D), + KeyCode::F5 => (0x74, 0x3E), + KeyCode::F6 => (0x75, 0x3F), + KeyCode::F7 => (0x76, 0x40), + KeyCode::F8 => (0x77, 0x41), + KeyCode::F9 => (0x78, 0x42), + KeyCode::F10 => (0x79, 0x43), + KeyCode::F11 => (0x7A, 0x44), + KeyCode::F12 => (0x7B, 0x45), + _ => return None, + }) +} + +pub fn modifier_flags(shift: bool, ctrl: bool, alt: bool, meta: bool) -> u16 { + let mut flags = 0_u16; + if shift { flags |= 0x01; } + if ctrl { flags |= 0x02; } + if alt { flags |= 0x04; } + if meta { flags |= 0x08; } + flags +} + +pub fn mouse_button(button: MouseButton) -> Option { + Some(match button { + MouseButton::Left => 1, + MouseButton::Middle => 2, + MouseButton::Right => 3, + MouseButton::Back => 4, + MouseButton::Forward => 5, + _ => return None, + }) +} diff --git a/opennow-streamer/src/main.rs b/opennow-streamer/src/main.rs new file mode 100644 index 00000000..14e075a9 --- /dev/null +++ b/opennow-streamer/src/main.rs @@ -0,0 +1,56 @@ +mod control; +mod input; +mod media; +mod messages; +mod sdp; +mod session; +mod window; + +use std::sync::{Arc, mpsc}; + +use clap::Parser; +use tokio::sync::Mutex; + +use crate::{ + control::{connect, send}, + media::MediaEvent, + messages::{StreamerMessage, StreamerState}, + session::{handle_control_message, SharedSession}, +}; + +#[derive(Parser, Debug)] +struct Args { + #[arg(long)] + control_url: String, +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> anyhow::Result<()> { + env_logger::init(); + let args = Args::parse(); + let (control_tx, mut control_rx) = connect(&args.control_url).await?; + send(&control_tx, StreamerMessage::Hello { version: 1, pid: std::process::id() }).await; + send(&control_tx, StreamerMessage::State { state: StreamerState::Idle, detail: Some("booted".into()) }).await; + + let active: SharedSession = Arc::new(Mutex::new(None)); + let window_session = active.clone(); + let (media_tx, media_rx) = mpsc::channel::(); + std::thread::spawn(move || { + if let Err(error) = window::run(window_session, media_rx, 1920, 1080) { + eprintln!("window loop failed: {error:#}"); + } + }); + + while let Some(message) = control_rx.recv().await { + if let Err(error) = handle_control_message(&active, &control_tx, &media_tx, message).await { + let _ = control_tx + .send(StreamerMessage::State { + state: StreamerState::Failed, + detail: Some(error.to_string()), + }) + .await; + } + } + + Ok(()) +} diff --git a/opennow-streamer/src/media.rs b/opennow-streamer/src/media.rs new file mode 100644 index 00000000..d855c6ff --- /dev/null +++ b/opennow-streamer/src/media.rs @@ -0,0 +1,288 @@ +use std::{ + env, + io::{Read, Write}, + path::{Path, PathBuf}, + process::{Child, ChildStdin, Command, Stdio}, + sync::{mpsc::Sender, Arc}, + thread, +}; + +use anyhow::{anyhow, Context}; +use bytes::Bytes; +use opus::{Channels, Decoder as OpusDecoder}; +use rtp::{ + codecs::{h264::H264Packet, h265::{H265Packet, H265Payload}}, + packetizer::Depacketizer, +}; +use webrtc::rtp_transceiver::rtp_codec::RTCRtpCodecCapability; + +use crate::messages::StreamerMessage; + +#[derive(Clone)] +pub struct VideoFrame { + pub width: u32, + pub height: u32, + pub pixels: Vec, +} + +#[derive(Clone)] +pub struct AudioFrame { + pub samples: Vec, + pub channels: u8, + pub sample_rate: u32, +} + +#[derive(Clone)] +pub enum MediaEvent { + Video(VideoFrame), + Audio(AudioFrame), +} + +#[derive(Clone)] +pub struct MediaPipeline { + event_tx: Sender, + log_tx: tokio::sync::mpsc::Sender, + video_settings: VideoSettings, +} + +#[derive(Clone)] +pub struct VideoSettings { + pub width: u32, + pub height: u32, + pub codec: String, +} + +impl MediaPipeline { + pub fn new(event_tx: Sender, log_tx: tokio::sync::mpsc::Sender, video_settings: VideoSettings) -> Self { + Self { event_tx, log_tx, video_settings } + } + + pub async fn attach_video_track(&self, track: Arc) -> anyhow::Result<()> { + let codec = track.codec().capability; + let mime = codec.mime_type.to_lowercase(); + let event_tx = self.event_tx.clone(); + let log_tx = self.log_tx.clone(); + let settings = self.video_settings.clone(); + tokio::spawn(async move { + if let Err(error) = run_video_track(track, codec, settings, event_tx, log_tx.clone()).await { + let _ = log_tx.send(StreamerMessage::Error { message: format!("video pipeline failed: {error:#}") }).await; + } + }); + let _ = self.log_tx.send(StreamerMessage::Log { level: "info".into(), message: format!("attached video track {mime}") }).await; + Ok(()) + } + + pub async fn attach_audio_track(&self, track: Arc) -> anyhow::Result<()> { + let codec = track.codec().capability; + let event_tx = self.event_tx.clone(); + let log_tx = self.log_tx.clone(); + tokio::spawn(async move { + if let Err(error) = run_audio_track(track, codec, event_tx, log_tx.clone()).await { + let _ = log_tx.send(StreamerMessage::Error { message: format!("audio pipeline failed: {error:#}") }).await; + } + }); + Ok(()) + } +} + +async fn run_video_track( + track: Arc, + codec: RTCRtpCodecCapability, + settings: VideoSettings, + event_tx: Sender, + log_tx: tokio::sync::mpsc::Sender, +) -> anyhow::Result<()> { + let codec_name = codec.mime_type.to_lowercase(); + let ffmpeg_demuxer = if codec_name.contains("h265") || codec_name.contains("hevc") { + "hevc" + } else if codec_name.contains("h264") { + "h264" + } else { + return Err(anyhow!("unsupported video codec for MVP decode path: {}", codec.mime_type)); + }; + + let mut decoder = FfmpegVideoDecoder::spawn(ffmpeg_demuxer, settings.width, settings.height, event_tx, log_tx.clone())?; + let mut h264 = H264Packet::default(); + let mut h265 = H265Assembler::default(); + loop { + let (packet, _) = track.read_rtp().await.context("read_rtp video")?; + let payload = if ffmpeg_demuxer == "h264" { + match h264.depacketize(&packet.payload) { + Ok(bytes) if !bytes.is_empty() => bytes.as_ref().to_vec(), + Ok(_) => Vec::new(), + Err(error) => { + let _ = log_tx.send(StreamerMessage::Log { level: "warn".into(), message: format!("h264 depacketize: {error}") }).await; + Vec::new() + } + } + } else { + match h265.push(packet.payload.clone()) { + Ok(bytes) => bytes, + Err(error) => { + let _ = log_tx.send(StreamerMessage::Log { level: "warn".into(), message: format!("h265 depacketize: {error}") }).await; + Vec::new() + } + } + }; + if !payload.is_empty() { + decoder.write(&payload)?; + } + } +} + +async fn run_audio_track( + track: Arc, + codec: RTCRtpCodecCapability, + event_tx: Sender, + _log_tx: tokio::sync::mpsc::Sender, +) -> anyhow::Result<()> { + if !codec.mime_type.to_lowercase().contains("opus") { + return Err(anyhow!("unsupported audio codec for MVP decode path: {}", codec.mime_type)); + } + let sample_rate = codec.clock_rate.max(48_000); + let channels = if codec.channels == 0 { 2 } else { codec.channels as usize }; + let mut depacketizer = rtp::codecs::opus::OpusPacket::default(); + let mut decoder = OpusDecoder::new(sample_rate, if channels > 1 { Channels::Stereo } else { Channels::Mono })?; + let mut pcm = vec![0_i16; 960 * channels * 6]; + loop { + let (packet, _) = track.read_rtp().await.context("read_rtp audio")?; + let opus = depacketizer.depacketize(&packet.payload).context("depacketize opus")?; + let frame_samples = decoder.decode(&opus, &mut pcm, false).context("decode opus")?; + if frame_samples > 0 { + let used = frame_samples * channels; + let samples = pcm[..used].to_vec(); + let _ = event_tx.send(MediaEvent::Audio(AudioFrame { samples, channels: channels as u8, sample_rate })); + } + } +} + +struct FfmpegVideoDecoder { + stdin: ChildStdin, + _child: Child, +} + +impl FfmpegVideoDecoder { + fn spawn( + demuxer: &str, + width: u32, + height: u32, + event_tx: Sender, + log_tx: tokio::sync::mpsc::Sender, + ) -> anyhow::Result { + let ffmpeg = resolve_ffmpeg_binary()?; + let mut child = Command::new(ffmpeg) + .args([ + "-loglevel", "error", + "-fflags", "nobuffer", + "-flags", "low_delay", + "-probesize", "32", + "-analyzeduration", "0", + "-f", demuxer, + "-i", "pipe:0", + "-f", "rawvideo", + "-pix_fmt", "rgb24", + "pipe:1", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("spawn ffmpeg video decoder")?; + let frame_size = (width * height * 3) as usize; + let mut stdout = child.stdout.take().ok_or_else(|| anyhow!("missing ffmpeg stdout"))?; + let mut stderr = child.stderr.take().ok_or_else(|| anyhow!("missing ffmpeg stderr"))?; + thread::spawn(move || { + let mut buffer = vec![0_u8; frame_size]; + while stdout.read_exact(&mut buffer).is_ok() { + let _ = event_tx.send(MediaEvent::Video(VideoFrame { width, height, pixels: buffer.clone() })); + } + }); + thread::spawn(move || { + let mut stderr_buf = String::new(); + let _ = stderr.read_to_string(&mut stderr_buf); + if !stderr_buf.trim().is_empty() { + let _ = log_tx.blocking_send(StreamerMessage::Log { level: "stderr".into(), message: stderr_buf }); + } + }); + let stdin = child.stdin.take().ok_or_else(|| anyhow!("missing ffmpeg stdin"))?; + Ok(Self { stdin, _child: child }) + } + + fn write(&mut self, payload: &[u8]) -> anyhow::Result<()> { + self.stdin.write_all(payload).context("write ffmpeg stdin")?; + Ok(()) + } +} + +#[derive(Default)] +struct H265Assembler { + packet: H265Packet, + fu_buffer: Vec, +} + +impl H265Assembler { + fn push(&mut self, payload: Bytes) -> anyhow::Result> { + self.packet.depacketize(&payload)?; + match self.packet.payload() { + H265Payload::H265SingleNALUnitPacket(packet) => { + let mut out = vec![0, 0, 0, 1]; + out.extend_from_slice(&packet.payload_header().0.to_be_bytes()); + out.extend_from_slice(&packet.payload()); + Ok(out) + } + H265Payload::H265AggregationPacket(packet) => { + let mut out = Vec::new(); + if let Some(first) = packet.first_unit() { + out.extend_from_slice(&[0, 0, 0, 1]); + let nal: Bytes = first.nal_unit(); + out.extend_from_slice(nal.as_ref()); + } + for unit in packet.other_units() { + out.extend_from_slice(&[0, 0, 0, 1]); + let nal: Bytes = unit.nal_unit(); + out.extend_from_slice(nal.as_ref()); + } + Ok(out) + } + H265Payload::H265FragmentationUnitPacket(packet) => { + if packet.fu_header().s() { + self.fu_buffer.clear(); + self.fu_buffer.extend_from_slice(&[0, 0, 0, 1]); + let header = packet.payload_header(); + let reconstructed0 = ((header.f() as u8) << 7) | ((packet.fu_header().fu_type() & 0x3F) << 1) | (header.layer_id() & 0x01); + let reconstructed1 = ((header.layer_id() as u8) << 3) | (header.tid() & 0x07); + self.fu_buffer.push(reconstructed0); + self.fu_buffer.push(reconstructed1); + } + self.fu_buffer.extend_from_slice(&packet.payload()); + if packet.fu_header().e() { + Ok(std::mem::take(&mut self.fu_buffer)) + } else { + Ok(Vec::new()) + } + } + H265Payload::H265PACIPacket(packet) => { + let mut out = vec![0, 0, 0, 1]; + out.extend_from_slice(&packet.payload()); + Ok(out) + } + } + } +} + +fn resolve_ffmpeg_binary() -> anyhow::Result { + if let Ok(path) = env::var("OPENNOW_FFMPEG_BIN") { + let candidate = PathBuf::from(path); + if candidate.exists() { + return Ok(candidate); + } + } + let exe = env::current_exe().context("current_exe")?; + let suffix = if cfg!(target_os = "windows") { ".exe" } else { "" }; + let candidates = [ + exe.parent().map(|p| p.join(format!("ffmpeg{suffix}"))), + exe.parent().and_then(Path::parent).map(|p| p.join("bin").join(format!("ffmpeg{suffix}"))), + Some(PathBuf::from(format!("ffmpeg{suffix}"))), + ]; + candidates.into_iter().flatten().find(|candidate| candidate.exists() || candidate == &PathBuf::from(format!("ffmpeg{suffix}"))).ok_or_else(|| anyhow!("unable to locate bundled ffmpeg runtime")) +} diff --git a/opennow-streamer/src/messages.rs b/opennow-streamer/src/messages.rs new file mode 100644 index 00000000..d7dd4aa3 --- /dev/null +++ b/opennow-streamer/src/messages.rs @@ -0,0 +1,73 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IceServer { + pub urls: Vec, + #[serde(default)] + pub username: Option, + #[serde(default)] + pub credential: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaConnectionInfo { + pub ip: String, + pub port: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionInfo { + pub session_id: String, + pub server_ip: String, + pub signaling_server: String, + #[serde(default)] + pub signaling_url: Option, + pub ice_servers: Vec, + #[serde(default)] + pub media_connection_info: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StreamSettings { + pub resolution: String, + pub fps: u16, + pub max_bitrate_mbps: u16, + pub codec: String, + pub color_quality: String, + pub enable_l4s: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum ControlMessage { + Configure { session: SessionInfo, settings: StreamSettings }, + SignalingOffer { sdp: String }, + SignalingRemoteIce { candidate: String, sdp_mid: Option, sdp_m_line_index: Option }, + Stop, + Ping, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum StreamerMessage { + Hello { version: u32, pid: u32 }, + Log { level: String, message: String }, + State { state: StreamerState, detail: Option }, + Answer { sdp: String, nvst_sdp: String }, + LocalIce { candidate: String, sdp_mid: Option, sdp_m_line_index: Option }, + Error { message: String }, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum StreamerState { + Idle, + Connecting, + Connected, + Disconnected, + Failed, +} diff --git a/opennow-streamer/src/sdp.rs b/opennow-streamer/src/sdp.rs new file mode 100644 index 00000000..9c515ffe --- /dev/null +++ b/opennow-streamer/src/sdp.rs @@ -0,0 +1,184 @@ +pub fn extract_public_ip(host_or_ip: &str) -> Option { + if host_or_ip.is_empty() { + return None; + } + if host_or_ip.split('.').count() == 4 && host_or_ip.split('.').all(|p| p.parse::().is_ok()) { + return Some(host_or_ip.to_string()); + } + let first = host_or_ip.split('.').next().unwrap_or_default(); + let parts: Vec<_> = first.split('-').collect(); + if parts.len() == 4 && parts.iter().all(|p| p.parse::().is_ok()) { + return Some(parts.join(".")); + } + None +} + +pub fn fix_server_ip(sdp: &str, server_ip: &str) -> String { + let Some(ip) = extract_public_ip(server_ip) else { + return sdp.to_string(); + }; + let fixed = sdp.replace("c=IN IP4 0.0.0.0", &format!("c=IN IP4 {ip}")); + fixed + .lines() + .map(|line| { + if line.starts_with("a=candidate:") && line.contains(" 0.0.0.0 ") { + line.replacen(" 0.0.0.0 ", &format!(" {ip} "), 1) + } else { + line.to_string() + } + }) + .collect::>() + .join("\r\n") +} + +pub fn parse_partial_reliable_threshold_ms(sdp: &str) -> Option { + sdp.lines() + .find_map(|line| line.trim().strip_prefix("a=ri.partialReliableThresholdMs:")) + .and_then(|value| value.trim().parse::().ok()) +} + +pub fn extract_ice_ufrag_from_offer(sdp: &str) -> String { + sdp.lines() + .find_map(|line| line.trim().strip_prefix("a=ice-ufrag:")) + .map(|value| value.trim().to_string()) + .unwrap_or_default() +} + +pub fn munge_answer_sdp(sdp: &str, max_bitrate_kbps: u32) -> String { + let mut out = Vec::new(); + let mut current_media = String::new(); + let mut inserted_bitrate = false; + for line in sdp.lines() { + if line.starts_with("m=") { + current_media = line.to_string(); + inserted_bitrate = false; + out.push(line.to_string()); + continue; + } + if !inserted_bitrate && (line.starts_with("c=") || line.starts_with("a=mid:")) { + out.push(line.to_string()); + if current_media.starts_with("m=video") { + out.push(format!("b=AS:{max_bitrate_kbps}")); + inserted_bitrate = true; + } + continue; + } + if line.starts_with("a=fmtp:") && current_media.starts_with("m=audio") && !line.contains("stereo=1") { + out.push(format!("{line};stereo=1;sprop-stereo=1")); + continue; + } + out.push(line.to_string()); + } + out.join("\r\n") +} + +pub fn prefer_codec(sdp: &str, codec: &str) -> String { + let target = codec.to_uppercase(); + let mut allowed_pts = Vec::::new(); + let mut rtx_by_pt = Vec::<(String, String)>::new(); + let mut in_video = false; + for line in sdp.lines() { + if line.starts_with("m=video") { + in_video = true; + continue; + } + if line.starts_with("m=") && in_video { + in_video = false; + } + if !in_video { continue; } + if let Some(rest) = line.strip_prefix("a=rtpmap:") { + let mut parts = rest.split_whitespace(); + let pt = parts.next().unwrap_or_default(); + let codec_name = parts.next().unwrap_or_default().split('/').next().unwrap_or_default().to_uppercase(); + let normalized = if codec_name == "HEVC" { "H265".to_string() } else { codec_name }; + if normalized == target { + allowed_pts.push(pt.to_string()); + } + } + } + in_video = false; + for line in sdp.lines() { + if line.starts_with("m=video") { + in_video = true; + continue; + } + if line.starts_with("m=") && in_video { in_video = false; } + if !in_video { continue; } + if let Some(rest) = line.strip_prefix("a=fmtp:") { + let mut parts = rest.split_whitespace(); + let pt = parts.next().unwrap_or_default().to_string(); + let params = parts.next().unwrap_or_default(); + if let Some(apt) = params.split(';').find_map(|entry| entry.trim().strip_prefix("apt=")) { + if allowed_pts.iter().any(|allowed| allowed == apt) { + rtx_by_pt.push((pt, apt.to_string())); + } + } + } + } + let mut keep = allowed_pts.clone(); + for (pt, _) in &rtx_by_pt { + keep.push(pt.clone()); + } + let mut result = Vec::new(); + in_video = false; + for line in sdp.lines() { + if line.starts_with("m=video") { + in_video = true; + let mut parts: Vec<_> = line.split_whitespace().map(ToString::to_string).collect(); + if parts.len() > 3 { + parts.truncate(3); + parts.extend(keep.iter().cloned()); + } + result.push(parts.join(" ")); + continue; + } + if line.starts_with("m=") && in_video { in_video = false; } + if in_video && (line.starts_with("a=rtpmap:") || line.starts_with("a=rtcp-fb:") || line.starts_with("a=fmtp:")) { + let pt = line.split(':').nth(1).unwrap_or_default().split_whitespace().next().unwrap_or_default(); + if !keep.iter().any(|keep_pt| keep_pt == pt) { + continue; + } + } + result.push(line.to_string()); + } + result.join("\r\n") +} + +pub fn rewrite_h265_offer(sdp: &str) -> String { + sdp.lines() + .map(|line| { + if line.starts_with("a=fmtp:") { + line.replace("tier-flag=1", "tier-flag=0") + } else { + line.to_string() + } + }) + .collect::>() + .join("\r\n") +} + +pub fn build_nvst_sdp( + resolution: &str, + fps: u16, + max_bitrate_mbps: u16, + codec: &str, + color_quality: &str, + enable_l4s: bool, + partial_reliable_threshold_ms: u16, +) -> String { + let mut parts = resolution.split('x'); + let width = parts.next().and_then(|v| v.parse::().ok()).unwrap_or(1920); + let height = parts.next().and_then(|v| v.parse::().ok()).unwrap_or(1080); + let bit_depth = if color_quality.starts_with("10bit") { 10 } else { 8 }; + let chroma = if color_quality.ends_with("444") { 444 } else { 420 }; + format!( + "v=0\r\no=OpenNOW 0 0 IN IP4 127.0.0.1\r\ns=OpenNOW NVST\r\nt=0 0\r\na=x-nv-general.featureFlags:0\r\na=x-nv-video[0].clientViewportWd:{width}\r\na=x-nv-video[0].clientViewportHt:{height}\r\na=video.codec:{}\r\na=video.maxFPS:{}\r\na=video.bitDepth:{}\r\na=video.chroma:{}\r\na=bwe.maxBitrateKbps:{}\r\na=vqos.l4s:{}\r\na=ri.partialReliableThresholdMs:{}\r\nm=video 9 RTP/AVP 96\r\na=recvonly\r\nm=audio 9 RTP/AVP 111\r\na=recvonly\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\na=sendrecv\r\n", + codec.to_uppercase(), + fps, + bit_depth, + chroma, + u32::from(max_bitrate_mbps) * 1000, + if enable_l4s { 1 } else { 0 }, + partial_reliable_threshold_ms, + ) +} diff --git a/opennow-streamer/src/session.rs b/opennow-streamer/src/session.rs new file mode 100644 index 00000000..b3573e6e --- /dev/null +++ b/opennow-streamer/src/session.rs @@ -0,0 +1,239 @@ +use std::{sync::{Arc, mpsc::Sender as StdSender}}; + +use anyhow::{anyhow, Context}; +use tokio::sync::{mpsc, Mutex}; +use webrtc::{ + api::{media_engine::MediaEngine, APIBuilder}, + data_channel::{data_channel_init::RTCDataChannelInit, RTCDataChannel}, + ice_transport::{ice_candidate::RTCIceCandidateInit, ice_server::RTCIceServer}, + peer_connection::{configuration::RTCConfiguration, sdp::session_description::RTCSessionDescription, RTCPeerConnection}, + track::track_remote::TrackRemote, +}; + +use crate::{ + input, + media::{MediaEvent, MediaPipeline, VideoSettings}, + messages::{ControlMessage, SessionInfo, StreamSettings, StreamerMessage, StreamerState}, + sdp::{build_nvst_sdp, extract_ice_ufrag_from_offer, fix_server_ip, munge_answer_sdp, parse_partial_reliable_threshold_ms, prefer_codec, rewrite_h265_offer, extract_public_ip}, +}; + +pub struct StreamSession { + peer: Arc, + reliable: Arc, + partially_reliable: Arc>>>, + control_tx: mpsc::Sender, + session: SessionInfo, + settings: StreamSettings, + media: MediaPipeline, +} + +impl StreamSession { + pub async fn new( + control_tx: mpsc::Sender, + session: SessionInfo, + settings: StreamSettings, + media_tx: StdSender, + ) -> anyhow::Result { + let mut media_engine = MediaEngine::default(); + media_engine.register_default_codecs().context("register_default_codecs")?; + let api = APIBuilder::new().with_media_engine(media_engine).build(); + let config = RTCConfiguration { + ice_servers: session.ice_servers.iter().map(|server| RTCIceServer { + urls: server.urls.clone(), + username: server.username.clone().unwrap_or_default(), + credential: server.credential.clone().unwrap_or_default(), + ..Default::default() + }).collect(), + ..Default::default() + }; + let peer = Arc::new(api.new_peer_connection(config).await.context("new_peer_connection")?); + let reliable = peer.create_data_channel("input_channel_v1", Some(RTCDataChannelInit { ordered: Some(true), ..Default::default() })).await?; + let partially_reliable = Arc::new(Mutex::new(None)); + + let width = settings.resolution.split('x').next().and_then(|v| v.parse::().ok()).unwrap_or(1920); + let height = settings.resolution.split('x').nth(1).and_then(|v| v.parse::().ok()).unwrap_or(1080); + let media = MediaPipeline::new(media_tx, control_tx.clone(), VideoSettings { width, height, codec: settings.codec.clone() }); + + let control_clone = control_tx.clone(); + peer.on_ice_candidate(Box::new(move |candidate| { + let sender = control_clone.clone(); + Box::pin(async move { + if let Some(candidate) = candidate { + if let Ok(json) = candidate.to_json() { + let _ = sender.send(StreamerMessage::LocalIce { + candidate: json.candidate, + sdp_mid: json.sdp_mid, + sdp_m_line_index: json.sdp_mline_index, + }).await; + } + } + }) + })); + + let control_clone = control_tx.clone(); + peer.on_peer_connection_state_change(Box::new(move |state| { + let sender = control_clone.clone(); + Box::pin(async move { + let mapped = match state.to_string().as_str() { + "connected" => StreamerState::Connected, + "failed" => StreamerState::Failed, + "disconnected" | "closed" => StreamerState::Disconnected, + _ => StreamerState::Connecting, + }; + let _ = sender.send(StreamerMessage::State { state: mapped, detail: Some(state.to_string()) }).await; + }) + })); + + let media_clone = media.clone(); + let control_clone = control_tx.clone(); + peer.on_track(Box::new(move |track: Arc, _, _| { + let media = media_clone.clone(); + let control = control_clone.clone(); + Box::pin(async move { + let mime = track.codec().capability.mime_type.clone(); + let _ = control.send(StreamerMessage::Log { level: "info".into(), message: format!("track {mime}") }).await; + if mime.to_lowercase().contains("video") { + let _ = media.attach_video_track(track).await; + } else if mime.to_lowercase().contains("audio") { + let _ = media.attach_audio_track(track).await; + } + }) + })); + + Ok(Self { peer, reliable, partially_reliable, control_tx, session, settings, media }) + } + + pub async fn apply_offer(&self, offer_sdp: String) -> anyhow::Result<()> { + let partial_reliable = parse_partial_reliable_threshold_ms(&offer_sdp).unwrap_or(30); + if self.partially_reliable.lock().await.is_none() { + let channel = self.peer.create_data_channel("input_channel_partially_reliable", Some(RTCDataChannelInit { ordered: Some(false), max_packet_life_time: Some(partial_reliable), ..Default::default() })).await?; + *self.partially_reliable.lock().await = Some(channel); + } + let server_ip_for_sdp = self.session.media_connection_info.as_ref().map(|m| m.ip.as_str()).unwrap_or(self.session.server_ip.as_str()); + let mut processed = fix_server_ip(&offer_sdp, server_ip_for_sdp); + if self.settings.codec.eq_ignore_ascii_case("H265") { + processed = rewrite_h265_offer(&processed); + } + processed = prefer_codec(&processed, &self.settings.codec); + let server_ufrag = extract_ice_ufrag_from_offer(&processed); + + self.peer.set_remote_description(RTCSessionDescription::offer(processed)?).await?; + let mut answer = self.peer.create_answer(None).await?; + answer.sdp = munge_answer_sdp(&answer.sdp, u32::from(self.settings.max_bitrate_mbps) * 1000); + self.peer.set_local_description(answer).await?; + let local = self.peer.local_description().await.ok_or_else(|| anyhow!("missing local description"))?; + let nvst = build_nvst_sdp( + &self.settings.resolution, + self.settings.fps, + self.settings.max_bitrate_mbps, + &self.settings.codec, + &self.settings.color_quality, + self.settings.enable_l4s, + partial_reliable, + ); + self.control_tx.send(StreamerMessage::Answer { sdp: local.sdp.clone(), nvst_sdp: nvst }).await.ok(); + + if let Some(mci) = &self.session.media_connection_info { + if let Some(ip) = extract_public_ip(&mci.ip) { + let candidate = format!("candidate:1 1 udp 2130706431 {ip} {} typ host", mci.port); + for mid in ["0", "1", "2", "3"] { + let res = self.peer.add_ice_candidate(RTCIceCandidateInit { + candidate: candidate.clone(), + sdp_mid: Some(mid.to_string()), + sdp_mline_index: mid.parse::().ok(), + username_fragment: Some(server_ufrag.clone()), + }).await; + if res.is_ok() { + break; + } + } + } + } + Ok(()) + } + + pub async fn add_remote_ice(&self, candidate: String, sdp_mid: Option, sdp_m_line_index: Option) -> anyhow::Result<()> { + self.peer.add_ice_candidate(RTCIceCandidateInit { candidate, sdp_mid, sdp_mline_index: sdp_m_line_index, username_fragment: None }).await?; + Ok(()) + } + + pub async fn send_input(&self, payload: InputPayload) { + match payload { + InputPayload::Key { key_code, scan_code, modifiers, down } => { + let bytes = input::encode_key(key_code, scan_code, modifiers, down); + let _ = self.reliable.send(&bytes.into()).await; + } + InputPayload::MouseMove { dx, dy } => { + let bytes = input::encode_mouse_move(dx, dy); + let _ = self.reliable.send(&bytes.into()).await; + } + InputPayload::MouseButton { button, down } => { + let bytes = input::encode_mouse_button(button, down); + let _ = self.reliable.send(&bytes.into()).await; + } + InputPayload::Gamepad { buttons, left_trigger, right_trigger, left_x, left_y, right_x, right_y } => { + let bytes = input::encode_gamepad(buttons, left_trigger, right_trigger, left_x, left_y, right_x, right_y); + if let Some(channel) = self.partially_reliable.lock().await.clone() { + let _ = channel.send(&bytes.into()).await; + } else { + let _ = self.reliable.send(&bytes.into()).await; + } + } + } + } + + pub async fn close(&self) { + let _ = self.peer.close().await; + } +} + +#[derive(Clone)] +pub enum InputPayload { + Key { key_code: u16, scan_code: u16, modifiers: u16, down: bool }, + MouseMove { dx: i16, dy: i16 }, + MouseButton { button: u8, down: bool }, + Gamepad { buttons: u16, left_trigger: u8, right_trigger: u8, left_x: i16, left_y: i16, right_x: i16, right_y: i16 }, +} + +pub type SharedSession = Arc>>>; + +pub async fn handle_control_message( + active: &SharedSession, + control_tx: &mpsc::Sender, + media_tx: &StdSender, + message: ControlMessage, +) -> anyhow::Result { + match message { + ControlMessage::Configure { session, settings } => { + if let Some(current) = active.lock().await.take() { + current.close().await; + } + let session = Arc::new(StreamSession::new(control_tx.clone(), session, settings, media_tx.clone()).await?); + *active.lock().await = Some(session); + control_tx.send(StreamerMessage::State { state: StreamerState::Connecting, detail: Some("configured".into()) }).await.ok(); + Ok(true) + } + ControlMessage::SignalingOffer { sdp } => { + let session = active.lock().await.clone().ok_or_else(|| anyhow!("no active session"))?; + session.apply_offer(sdp).await?; + Ok(true) + } + ControlMessage::SignalingRemoteIce { candidate, sdp_mid, sdp_m_line_index } => { + if let Some(session) = active.lock().await.clone() { + session.add_remote_ice(candidate, sdp_mid, sdp_m_line_index).await?; + } + Ok(true) + } + ControlMessage::Stop => { + if let Some(session) = active.lock().await.take() { + session.close().await; + } + control_tx.send(StreamerMessage::State { state: StreamerState::Idle, detail: Some("stopped".into()) }).await.ok(); + Ok(false) + } + ControlMessage::Ping => { + control_tx.send(StreamerMessage::Log { level: "debug".into(), message: "pong".into() }).await.ok(); + Ok(true) + } + } +} diff --git a/opennow-streamer/src/window.rs b/opennow-streamer/src/window.rs new file mode 100644 index 00000000..69c45636 --- /dev/null +++ b/opennow-streamer/src/window.rs @@ -0,0 +1,227 @@ +use std::{sync::mpsc::Receiver, time::Duration}; + +use anyhow::Context; +use sdl2::{ + audio::{AudioQueue, AudioSpecDesired}, + controller::Button as ControllerButton, + event::Event, + keyboard::{Mod, Scancode}, + pixels::PixelFormatEnum, + rect::Rect, + render::TextureCreator, + video::WindowContext, +}; + +use crate::{ + media::{AudioFrame, MediaEvent, VideoFrame}, + session::{InputPayload, SharedSession}, +}; + +pub fn run(session: SharedSession, media_rx: Receiver, width: u32, height: u32) -> anyhow::Result<()> { + let sdl = sdl2::init().map_err(|e| anyhow::anyhow!(e)).context("sdl init")?; + let video = sdl.video().map_err(|e| anyhow::anyhow!(e)).context("sdl video")?; + let audio = sdl.audio().map_err(|e| anyhow::anyhow!(e)).context("sdl audio")?; + let game_controller = sdl.game_controller().ok(); + let mut opened_controller = None; + if let Some(gc) = &game_controller { + for idx in 0_u32..gc.num_joysticks().unwrap_or(0) { + if gc.is_game_controller(idx) { + if let Ok(controller) = gc.open(idx) { + opened_controller = Some(controller); + break; + } + } + } + } + + let window = video + .window("OpenNOW Streamer", width.max(640), height.max(360)) + .position_centered() + .resizable() + .build() + .context("create SDL window")?; + let mut canvas = window.into_canvas().accelerated().present_vsync().build().context("create SDL canvas")?; + let texture_creator: TextureCreator = canvas.texture_creator(); + let mut texture = texture_creator + .create_texture_streaming(PixelFormatEnum::RGB24, width, height) + .context("create texture")?; + + let queue: AudioQueue = audio + .open_queue::(None, &AudioSpecDesired { freq: Some(48_000), channels: Some(2), samples: Some(1024) }) + .map_err(|e| anyhow::anyhow!(e)) + .context("open SDL audio queue")?; + queue.resume(); + + let mut event_pump = sdl.event_pump().map_err(|e| anyhow::anyhow!(e)).context("event pump")?; + let mut latest_frame: Option = None; + let mut running = true; + while running { + while let Ok(event) = media_rx.try_recv() { + match event { + MediaEvent::Video(frame) => latest_frame = Some(frame), + MediaEvent::Audio(frame) => queue_audio(&queue, frame), + } + } + + if let Some(frame) = latest_frame.take() { + texture.update(None, &frame.pixels, (frame.width * 3) as usize).context("texture update")?; + } + + canvas.clear(); + let (out_w, out_h) = canvas.output_size().unwrap_or((width, height)); + canvas.copy(&texture, None, Some(Rect::new(0, 0, out_w, out_h))).ok(); + canvas.present(); + + for event in event_pump.poll_iter() { + match event { + Event::Quit { .. } => running = false, + Event::KeyDown { scancode: Some(scancode), keymod, repeat, .. } => { + if !repeat { + if let Some((vk, code)) = map_scancode(scancode) { + send_input(&session, InputPayload::Key { + key_code: vk, + scan_code: code, + modifiers: map_modifiers(keymod), + down: true, + }); + } + } + } + Event::KeyUp { scancode: Some(scancode), keymod, repeat, .. } => { + if !repeat { + if let Some((vk, code)) = map_scancode(scancode) { + send_input(&session, InputPayload::Key { + key_code: vk, + scan_code: code, + modifiers: map_modifiers(keymod), + down: false, + }); + } + } + } + Event::MouseMotion { xrel, yrel, .. } => { + if xrel != 0 || yrel != 0 { + send_input(&session, InputPayload::MouseMove { dx: xrel.clamp(i16::MIN as i32, i16::MAX as i32) as i16, dy: yrel.clamp(i16::MIN as i32, i16::MAX as i32) as i16 }); + } + } + Event::MouseButtonDown { mouse_btn, .. } => { + if let Some(button) = map_mouse_button(mouse_btn) { + send_input(&session, InputPayload::MouseButton { button, down: true }); + } + } + Event::MouseButtonUp { mouse_btn, .. } => { + if let Some(button) = map_mouse_button(mouse_btn) { + send_input(&session, InputPayload::MouseButton { button, down: false }); + } + } + Event::ControllerAxisMotion { .. } + | Event::ControllerButtonDown { .. } + | Event::ControllerButtonUp { .. } + | Event::ControllerDeviceAdded { .. } + | Event::ControllerDeviceRemoved { .. } => { + if let Some(controller) = opened_controller.as_ref() { + send_input(&session, InputPayload::Gamepad { + buttons: map_controller_buttons(controller), + left_trigger: axis_to_u8(controller.axis(sdl2::controller::Axis::TriggerLeft)), + right_trigger: axis_to_u8(controller.axis(sdl2::controller::Axis::TriggerRight)), + left_x: controller.axis(sdl2::controller::Axis::LeftX), + left_y: -controller.axis(sdl2::controller::Axis::LeftY), + right_x: controller.axis(sdl2::controller::Axis::RightX), + right_y: -controller.axis(sdl2::controller::Axis::RightY), + }); + } + } + _ => {} + } + } + std::thread::sleep(Duration::from_millis(4)); + } + + Ok(()) +} + +fn send_input(session: &SharedSession, payload: InputPayload) { + let session = session.clone(); + tokio::spawn(async move { + if let Some(active) = session.lock().await.clone() { + active.send_input(payload).await; + } + }); +} + +fn queue_audio(queue: &AudioQueue, frame: AudioFrame) { + let _ = (frame.channels, frame.sample_rate); + let _ = queue.queue_audio(&frame.samples); +} + +fn map_modifiers(mods: Mod) -> u16 { + let mut flags = 0_u16; + if mods.intersects(Mod::LSHIFTMOD | Mod::RSHIFTMOD) { flags |= 0x01; } + if mods.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD) { flags |= 0x02; } + if mods.intersects(Mod::LALTMOD | Mod::RALTMOD) { flags |= 0x04; } + if mods.intersects(Mod::LGUIMOD | Mod::RGUIMOD) { flags |= 0x08; } + if mods.intersects(Mod::CAPSMOD) { flags |= 0x10; } + if mods.intersects(Mod::NUMMOD) { flags |= 0x20; } + flags +} + +fn map_mouse_button(button: sdl2::mouse::MouseButton) -> Option { + Some(match button { + sdl2::mouse::MouseButton::Left => 1, + sdl2::mouse::MouseButton::Middle => 2, + sdl2::mouse::MouseButton::Right => 3, + sdl2::mouse::MouseButton::X1 => 4, + sdl2::mouse::MouseButton::X2 => 5, + _ => return None, + }) +} + +fn map_controller_buttons(controller: &sdl2::controller::GameController) -> u16 { + let mut buttons = 0_u16; + if controller.button(ControllerButton::DPadUp) { buttons |= 0x0001; } + if controller.button(ControllerButton::DPadDown) { buttons |= 0x0002; } + if controller.button(ControllerButton::DPadLeft) { buttons |= 0x0004; } + if controller.button(ControllerButton::DPadRight) { buttons |= 0x0008; } + if controller.button(ControllerButton::Start) { buttons |= 0x0010; } + if controller.button(ControllerButton::Back) { buttons |= 0x0020; } + if controller.button(ControllerButton::LeftStick) { buttons |= 0x0040; } + if controller.button(ControllerButton::RightStick) { buttons |= 0x0080; } + if controller.button(ControllerButton::LeftShoulder) { buttons |= 0x0100; } + if controller.button(ControllerButton::RightShoulder) { buttons |= 0x0200; } + if controller.button(ControllerButton::Guide) { buttons |= 0x0400; } + if controller.button(ControllerButton::A) { buttons |= 0x1000; } + if controller.button(ControllerButton::B) { buttons |= 0x2000; } + if controller.button(ControllerButton::X) { buttons |= 0x4000; } + if controller.button(ControllerButton::Y) { buttons |= 0x8000; } + buttons +} + +fn axis_to_u8(value: i16) -> u8 { + (((value.max(0) as f32) / 32767.0) * 255.0).round().clamp(0.0, 255.0) as u8 +} + +fn map_scancode(code: Scancode) -> Option<(u16, u16)> { + Some(match code { + Scancode::A => (0x41, 0x04), Scancode::B => (0x42, 0x05), Scancode::C => (0x43, 0x06), + Scancode::D => (0x44, 0x07), Scancode::E => (0x45, 0x08), Scancode::F => (0x46, 0x09), + Scancode::G => (0x47, 0x0A), Scancode::H => (0x48, 0x0B), Scancode::I => (0x49, 0x0C), + Scancode::J => (0x4A, 0x0D), Scancode::K => (0x4B, 0x0E), Scancode::L => (0x4C, 0x0F), + Scancode::M => (0x4D, 0x10), Scancode::N => (0x4E, 0x11), Scancode::O => (0x4F, 0x12), + Scancode::P => (0x50, 0x13), Scancode::Q => (0x51, 0x14), Scancode::R => (0x52, 0x15), + Scancode::S => (0x53, 0x16), Scancode::T => (0x54, 0x17), Scancode::U => (0x55, 0x18), + Scancode::V => (0x56, 0x19), Scancode::W => (0x57, 0x1A), Scancode::X => (0x58, 0x1B), + Scancode::Y => (0x59, 0x1C), Scancode::Z => (0x5A, 0x1D), + Scancode::Num1 => (0x31, 0x1E), Scancode::Num2 => (0x32, 0x1F), Scancode::Num3 => (0x33, 0x20), + Scancode::Num4 => (0x34, 0x21), Scancode::Num5 => (0x35, 0x22), Scancode::Num6 => (0x36, 0x23), + Scancode::Num7 => (0x37, 0x24), Scancode::Num8 => (0x38, 0x25), Scancode::Num9 => (0x39, 0x26), + Scancode::Num0 => (0x30, 0x27), Scancode::Return => (0x0D, 0x28), Scancode::Escape => (0x1B, 0x29), + Scancode::Backspace => (0x08, 0x2A), Scancode::Tab => (0x09, 0x2B), Scancode::Space => (0x20, 0x2C), + Scancode::Left => (0x25, 0x50), Scancode::Right => (0x27, 0x4F), Scancode::Up => (0x26, 0x52), Scancode::Down => (0x28, 0x51), + Scancode::LShift => (0xA0, 0xE1), Scancode::RShift => (0xA1, 0xE5), Scancode::LCtrl => (0xA2, 0xE0), Scancode::RCtrl => (0xA3, 0xE4), + Scancode::LAlt => (0xA4, 0xE2), Scancode::RAlt => (0xA5, 0xE6), Scancode::LGui => (0x5B, 0xE3), Scancode::RGui => (0x5C, 0xE7), + Scancode::F1 => (0x70, 0x3A), Scancode::F2 => (0x71, 0x3B), Scancode::F3 => (0x72, 0x3C), Scancode::F4 => (0x73, 0x3D), + Scancode::F5 => (0x74, 0x3E), Scancode::F6 => (0x75, 0x3F), Scancode::F7 => (0x76, 0x40), Scancode::F8 => (0x77, 0x41), + Scancode::F9 => (0x78, 0x42), Scancode::F10 => (0x79, 0x43), Scancode::F11 => (0x7A, 0x44), Scancode::F12 => (0x7B, 0x45), + _ => return None, + }) +} diff --git a/package.json b/package.json index ef11008e..51e117ae 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "npm --prefix opennow-stable run build", "typecheck": "npm --prefix opennow-stable run typecheck", "dist": "npm --prefix opennow-stable run dist", - "dist:signed": "npm --prefix opennow-stable run dist:signed" + "dist:signed": "npm --prefix opennow-stable run dist:signed", + "streamer:build": "cargo build --manifest-path opennow-streamer/Cargo.toml --release" } } From e27365a0ffb59b89a4b6b3be529ef7a3a038a613 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:24:17 +0000 Subject: [PATCH 02/30] fix streamer startup/input threading issues --- .../src/main/services/streamerManager.ts | 45 ++++++++++++++----- opennow-streamer/src/main.rs | 38 ++++++++++++---- opennow-streamer/src/window.rs | 25 +++++------ 3 files changed, 73 insertions(+), 35 deletions(-) diff --git a/opennow-stable/src/main/services/streamerManager.ts b/opennow-stable/src/main/services/streamerManager.ts index 1eb3f8f8..e57c9a9f 100644 --- a/opennow-stable/src/main/services/streamerManager.ts +++ b/opennow-stable/src/main/services/streamerManager.ts @@ -40,6 +40,20 @@ export class StreamerManager { private pendingReady: { resolve: () => void; reject: (error: Error) => void; timer: NodeJS.Timeout } | null = null; private mode: "idle" | "legacy" | "external" = "idle"; + private settlePendingReady(outcome: { resolve: true } | { resolve: false; error: Error }): void { + const pending = this.pendingReady; + if (!pending) { + return; + } + clearTimeout(pending.timer); + this.pendingReady = null; + if (outcome.resolve) { + pending.resolve(); + return; + } + pending.reject(outcome.error); + } + constructor( private readonly windowProvider: () => BrowserWindow | null, private readonly signalingHandlers: { @@ -69,6 +83,7 @@ export class StreamerManager { await this.stop(); const port = await this.createControlServer(); const binaryPath = this.resolveBinaryPath(); + const ready = this.waitForReady(); this.mode = "external"; this.emit({ type: "availability", available: true }); this.emit({ type: "state", state: "connecting", detail: "launching native streamer" }); @@ -89,22 +104,29 @@ export class StreamerManager { if (message) this.emit({ type: "log", level: "stderr", message }); }); + child.once("error", (error) => { + this.settlePendingReady({ resolve: false, error: new Error(`native streamer failed to launch: ${String(error)}`) }); + }); + child.once("exit", (code, signal) => { + const detail = `native streamer exited code=${code ?? "null"} signal=${signal ?? "null"}`; + this.settlePendingReady({ resolve: false, error: new Error(detail) }); this.emit({ type: "state", state: code === 0 ? "disconnected" : "failed", - detail: `native streamer exited code=${code ?? "null"} signal=${signal ?? "null"}`, + detail, }); this.cleanupSocket(); this.process = null; this.mode = "idle"; }); - await this.waitForReady(); + await ready; await this.sendControl({ type: "configure", session: request.session, settings: request.settings }); } async stop(): Promise { + this.settlePendingReady({ resolve: false, error: new Error("native streamer startup cancelled") }); if (this.socket && !this.socket.destroyed) { await this.sendControl({ type: "stop" }).catch(() => {}); } @@ -189,7 +211,13 @@ export class StreamerManager { } }); socket.on("close", () => { - this.socket = null; + if (this.socket === socket) { + this.socket = null; + } + this.settlePendingReady({ resolve: false, error: new Error("native streamer control socket closed before handshake") }); + }); + socket.on("error", (error) => { + this.settlePendingReady({ resolve: false, error: new Error(`native streamer control socket error: ${String(error)}`) }); }); }); @@ -209,18 +237,13 @@ export class StreamerManager { private waitForReady(): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { - this.pendingReady = null; - reject(new Error("Timed out waiting for native streamer handshake")); + this.settlePendingReady({ resolve: false, error: new Error("Timed out waiting for native streamer handshake") }); }, 10_000); this.pendingReady = { resolve, reject, timer }; }); } private cleanupSocket(): void { - if (this.pendingReady) { - clearTimeout(this.pendingReady.timer); - this.pendingReady = null; - } this.socket?.destroy(); this.socket = null; } @@ -245,9 +268,7 @@ export class StreamerManager { if (parsed.type === "hello") { if (this.pendingReady) { - clearTimeout(this.pendingReady.timer); - this.pendingReady.resolve(); - this.pendingReady = null; + this.settlePendingReady({ resolve: true }); } this.emit({ type: "log", level: "info", message: `native streamer connected pid=${parsed.pid ?? "unknown"}` }); return; diff --git a/opennow-streamer/src/main.rs b/opennow-streamer/src/main.rs index 14e075a9..29186c18 100644 --- a/opennow-streamer/src/main.rs +++ b/opennow-streamer/src/main.rs @@ -8,6 +8,8 @@ mod window; use std::sync::{Arc, mpsc}; +use tokio::sync::mpsc as tokio_mpsc; + use clap::Parser; use tokio::sync::Mutex; @@ -35,20 +37,38 @@ async fn main() -> anyhow::Result<()> { let active: SharedSession = Arc::new(Mutex::new(None)); let window_session = active.clone(); let (media_tx, media_rx) = mpsc::channel::(); + let (input_tx, mut input_rx) = tokio_mpsc::unbounded_channel(); std::thread::spawn(move || { - if let Err(error) = window::run(window_session, media_rx, 1920, 1080) { + if let Err(error) = window::run(window_session, media_rx, input_tx, 1920, 1080) { eprintln!("window loop failed: {error:#}"); } }); - while let Some(message) = control_rx.recv().await { - if let Err(error) = handle_control_message(&active, &control_tx, &media_tx, message).await { - let _ = control_tx - .send(StreamerMessage::State { - state: StreamerState::Failed, - detail: Some(error.to_string()), - }) - .await; + loop { + tokio::select! { + maybe_payload = input_rx.recv() => { + match maybe_payload { + Some(payload) => { + if let Some(active_session) = active.lock().await.clone() { + active_session.send_input(payload).await; + } + } + None => break, + } + } + maybe_message = control_rx.recv() => { + match maybe_message { + Some(message) => { + if let Err(error) = handle_control_message(&active, &control_tx, &media_tx, message).await { + let _ = control_tx.send(StreamerMessage::State { + state: StreamerState::Failed, + detail: Some(error.to_string()), + }).await; + } + } + None => break, + } + } } } diff --git a/opennow-streamer/src/window.rs b/opennow-streamer/src/window.rs index 69c45636..646ad16b 100644 --- a/opennow-streamer/src/window.rs +++ b/opennow-streamer/src/window.rs @@ -1,5 +1,7 @@ use std::{sync::mpsc::Receiver, time::Duration}; +use tokio::sync::mpsc::UnboundedSender; + use anyhow::Context; use sdl2::{ audio::{AudioQueue, AudioSpecDesired}, @@ -17,7 +19,7 @@ use crate::{ session::{InputPayload, SharedSession}, }; -pub fn run(session: SharedSession, media_rx: Receiver, width: u32, height: u32) -> anyhow::Result<()> { +pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: UnboundedSender, width: u32, height: u32) -> anyhow::Result<()> { let sdl = sdl2::init().map_err(|e| anyhow::anyhow!(e)).context("sdl init")?; let video = sdl.video().map_err(|e| anyhow::anyhow!(e)).context("sdl video")?; let audio = sdl.audio().map_err(|e| anyhow::anyhow!(e)).context("sdl audio")?; @@ -78,7 +80,7 @@ pub fn run(session: SharedSession, media_rx: Receiver, width: u32, h Event::KeyDown { scancode: Some(scancode), keymod, repeat, .. } => { if !repeat { if let Some((vk, code)) = map_scancode(scancode) { - send_input(&session, InputPayload::Key { + send_input(&input_tx, InputPayload::Key { key_code: vk, scan_code: code, modifiers: map_modifiers(keymod), @@ -90,7 +92,7 @@ pub fn run(session: SharedSession, media_rx: Receiver, width: u32, h Event::KeyUp { scancode: Some(scancode), keymod, repeat, .. } => { if !repeat { if let Some((vk, code)) = map_scancode(scancode) { - send_input(&session, InputPayload::Key { + send_input(&input_tx, InputPayload::Key { key_code: vk, scan_code: code, modifiers: map_modifiers(keymod), @@ -101,17 +103,17 @@ pub fn run(session: SharedSession, media_rx: Receiver, width: u32, h } Event::MouseMotion { xrel, yrel, .. } => { if xrel != 0 || yrel != 0 { - send_input(&session, InputPayload::MouseMove { dx: xrel.clamp(i16::MIN as i32, i16::MAX as i32) as i16, dy: yrel.clamp(i16::MIN as i32, i16::MAX as i32) as i16 }); + send_input(&input_tx, InputPayload::MouseMove { dx: xrel.clamp(i16::MIN as i32, i16::MAX as i32) as i16, dy: yrel.clamp(i16::MIN as i32, i16::MAX as i32) as i16 }); } } Event::MouseButtonDown { mouse_btn, .. } => { if let Some(button) = map_mouse_button(mouse_btn) { - send_input(&session, InputPayload::MouseButton { button, down: true }); + send_input(&input_tx, InputPayload::MouseButton { button, down: true }); } } Event::MouseButtonUp { mouse_btn, .. } => { if let Some(button) = map_mouse_button(mouse_btn) { - send_input(&session, InputPayload::MouseButton { button, down: false }); + send_input(&input_tx, InputPayload::MouseButton { button, down: false }); } } Event::ControllerAxisMotion { .. } @@ -120,7 +122,7 @@ pub fn run(session: SharedSession, media_rx: Receiver, width: u32, h | Event::ControllerDeviceAdded { .. } | Event::ControllerDeviceRemoved { .. } => { if let Some(controller) = opened_controller.as_ref() { - send_input(&session, InputPayload::Gamepad { + send_input(&input_tx, InputPayload::Gamepad { buttons: map_controller_buttons(controller), left_trigger: axis_to_u8(controller.axis(sdl2::controller::Axis::TriggerLeft)), right_trigger: axis_to_u8(controller.axis(sdl2::controller::Axis::TriggerRight)), @@ -140,13 +142,8 @@ pub fn run(session: SharedSession, media_rx: Receiver, width: u32, h Ok(()) } -fn send_input(session: &SharedSession, payload: InputPayload) { - let session = session.clone(); - tokio::spawn(async move { - if let Some(active) = session.lock().await.clone() { - active.send_input(payload).await; - } - }); +fn send_input(input_tx: &UnboundedSender, payload: InputPayload) { + let _ = input_tx.send(payload); } fn queue_audio(queue: &AudioQueue, frame: AudioFrame) { From 2d4cfe5d8c23297c733665f675ccd9d2361eb28d Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:44:16 +0000 Subject: [PATCH 03/30] add extra dev streamer binary lookup paths --- opennow-stable/src/main/services/streamerManager.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/opennow-stable/src/main/services/streamerManager.ts b/opennow-stable/src/main/services/streamerManager.ts index e57c9a9f..7aee601f 100644 --- a/opennow-stable/src/main/services/streamerManager.ts +++ b/opennow-stable/src/main/services/streamerManager.ts @@ -179,9 +179,21 @@ export class StreamerManager { const __filename = fileURLToPath(import.meta.url); const mainDir = dirname(__filename); const suffix = process.platform === "win32" ? ".exe" : ""; + const envOverride = process.env.OPENNOW_STREAMER_BIN; + const repoCandidates = [ + process.cwd(), + app.getAppPath(), + resolve(app.getAppPath(), ".."), + "/home/zortos/Projects/OpenNOW", + ].flatMap((root) => [ + resolve(root, `opennow-streamer/target/release/opennow-streamer${suffix}`), + resolve(root, `opennow-streamer/target/debug/opennow-streamer${suffix}`), + ]); const candidates = [ + ...(envOverride ? [envOverride] : []), resolve(mainDir, `../../../../opennow-streamer/target/release/opennow-streamer${suffix}`), resolve(mainDir, `../../../../opennow-streamer/target/debug/opennow-streamer${suffix}`), + ...repoCandidates, join(process.resourcesPath, "bin", `opennow-streamer${suffix}`), resolve(app.getAppPath(), `../opennow-streamer/target/release/opennow-streamer${suffix}`), ]; From fc7eeaa83f9f5f6fca72f55440a9ad2c26ec64fe Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:50:05 +0000 Subject: [PATCH 04/30] fix native startup settings and sdl runtime mismatch --- opennow-streamer/Cargo.toml | 2 +- opennow-streamer/src/messages.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/opennow-streamer/Cargo.toml b/opennow-streamer/Cargo.toml index b3f73202..f00fc912 100644 --- a/opennow-streamer/Cargo.toml +++ b/opennow-streamer/Cargo.toml @@ -9,7 +9,7 @@ license = "MIT" anyhow = "1" bytes = "1" opus = "0.3" -sdl2 = "0.36" +sdl2 = { version = "0.36", features = ["bundled"] } clap = { version = "4", features = ["derive"] } env_logger = "0.11" gilrs = "0.11" diff --git a/opennow-streamer/src/messages.rs b/opennow-streamer/src/messages.rs index d7dd4aa3..f6172168 100644 --- a/opennow-streamer/src/messages.rs +++ b/opennow-streamer/src/messages.rs @@ -38,6 +38,7 @@ pub struct StreamSettings { pub max_bitrate_mbps: u16, pub codec: String, pub color_quality: String, + #[serde(default, alias = "enableL4S", alias = "enableL4s")] pub enable_l4s: bool, } From 9d236bbb54b13c833bcace979b66698f5854a82c Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:50:19 +0000 Subject: [PATCH 05/30] update streamer lockfile for bundled sdl --- opennow-streamer/Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/opennow-streamer/Cargo.lock b/opennow-streamer/Cargo.lock index fe5a2793..fcb9d455 100644 --- a/opennow-streamer/Cargo.lock +++ b/opennow-streamer/Cargo.lock @@ -2707,6 +2707,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26bcacfdd45d539fb5785049feb0038a63931aa896c7763a2a12e125ec58bd29" dependencies = [ "cfg-if", + "cmake", "libc", "version-compare", ] From 9de6e378723baf8a2b7b87a8a20f7f50a066f896 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:54:08 +0000 Subject: [PATCH 06/30] fix bundled sdl build on newer cmake --- opennow-streamer/.cargo/config.toml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 opennow-streamer/.cargo/config.toml diff --git a/opennow-streamer/.cargo/config.toml b/opennow-streamer/.cargo/config.toml new file mode 100644 index 00000000..11d8c84f --- /dev/null +++ b/opennow-streamer/.cargo/config.toml @@ -0,0 +1,2 @@ +[env] +CMAKE_POLICY_VERSION_MINIMUM = "3.5" From 2cf9005c745e70f9b43eefa209efe61eec520df2 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:57:13 +0000 Subject: [PATCH 07/30] update bundled sdl to fix linux build --- opennow-streamer/Cargo.lock | 8 ++++---- opennow-streamer/Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/opennow-streamer/Cargo.lock b/opennow-streamer/Cargo.lock index fcb9d455..e1d7c52b 100644 --- a/opennow-streamer/Cargo.lock +++ b/opennow-streamer/Cargo.lock @@ -2690,9 +2690,9 @@ dependencies = [ [[package]] name = "sdl2" -version = "0.36.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8356b2697d1ead5a34f40bcc3c5d3620205fe0c7be0a14656223bfeec0258891" +checksum = "2d42407afc6a8ab67e36f92e80b8ba34cbdc55aaeed05249efe9a2e8d0e9feef" dependencies = [ "bitflags 1.3.2", "lazy_static", @@ -2702,9 +2702,9 @@ dependencies = [ [[package]] name = "sdl2-sys" -version = "0.36.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26bcacfdd45d539fb5785049feb0038a63931aa896c7763a2a12e125ec58bd29" +checksum = "3ff61407fc75d4b0bbc93dc7e4d6c196439965fbef8e4a4f003a36095823eac0" dependencies = [ "cfg-if", "cmake", diff --git a/opennow-streamer/Cargo.toml b/opennow-streamer/Cargo.toml index f00fc912..4b73a883 100644 --- a/opennow-streamer/Cargo.toml +++ b/opennow-streamer/Cargo.toml @@ -9,7 +9,7 @@ license = "MIT" anyhow = "1" bytes = "1" opus = "0.3" -sdl2 = { version = "0.36", features = ["bundled"] } +sdl2 = { version = "0.38", features = ["bundled"] } clap = { version = "4", features = ["derive"] } env_logger = "0.11" gilrs = "0.11" From 8c02b30fa899c67d571ead49e971b12d9d04a918 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:04:26 +0000 Subject: [PATCH 08/30] use system sdl on linux --- opennow-stable/scripts/bundle-native-runtime.mjs | 16 +++++++++++++++- .../src/main/services/streamerManager.ts | 10 +++++++++- opennow-streamer/.cargo/config.toml | 2 -- opennow-streamer/Cargo.lock | 1 + opennow-streamer/Cargo.toml | 7 ++++++- 5 files changed, 31 insertions(+), 5 deletions(-) delete mode 100644 opennow-streamer/.cargo/config.toml diff --git a/opennow-stable/scripts/bundle-native-runtime.mjs b/opennow-stable/scripts/bundle-native-runtime.mjs index ecc60c75..64ddd017 100644 --- a/opennow-stable/scripts/bundle-native-runtime.mjs +++ b/opennow-stable/scripts/bundle-native-runtime.mjs @@ -1,4 +1,4 @@ -import { cpSync, existsSync, mkdirSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, realpathSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; @@ -20,6 +20,20 @@ if (!streamer) { } cpSync(streamer, join(outDir, `opennow-streamer${exeSuffix}`)); +if (process.platform === 'linux') { + const sdlLibDir = execFileSync('pkg-config', ['--variable=libdir', 'sdl2'], { encoding: 'utf8' }).trim(); + const sdlCandidates = [ + join(sdlLibDir, 'libSDL2-2.0.so.0'), + '/lib/x86_64-linux-gnu/libSDL2-2.0.so.0', + '/usr/lib/x86_64-linux-gnu/libSDL2-2.0.so.0', + ]; + const sdlLib = sdlCandidates.find((candidate) => existsSync(candidate)); + if (!sdlLib) { + throw new Error(`Missing libSDL2-2.0.so.0. Checked: ${sdlCandidates.join(', ')}`); + } + cpSync(realpathSync(sdlLib), join(outDir, 'libSDL2-2.0.so.0')); +} + const ffmpegEnv = process.env.OPENNOW_FFMPEG_BIN; const ffmpeg = ffmpegEnv || execFileSync(process.platform === 'win32' ? 'where' : 'which', ['ffmpeg'], { encoding: 'utf8' }).split(/\r?\n/).find(Boolean); if (!ffmpeg || !existsSync(ffmpeg)) { diff --git a/opennow-stable/src/main/services/streamerManager.ts b/opennow-stable/src/main/services/streamerManager.ts index 7aee601f..df79495b 100644 --- a/opennow-stable/src/main/services/streamerManager.ts +++ b/opennow-stable/src/main/services/streamerManager.ts @@ -88,9 +88,17 @@ export class StreamerManager { this.emit({ type: "availability", available: true }); this.emit({ type: "state", state: "connecting", detail: "launching native streamer" }); + const runtimeEnv = { ...process.env }; + if (process.platform === "linux") { + const binaryDir = dirname(binaryPath); + runtimeEnv.LD_LIBRARY_PATH = [binaryDir, runtimeEnv.LD_LIBRARY_PATH] + .filter((value): value is string => Boolean(value && value.length > 0)) + .join(":"); + } + const child = spawn(binaryPath, ["--control-url", `tcp://127.0.0.1:${port}`], { stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env }, + env: runtimeEnv, }); this.process = child; diff --git a/opennow-streamer/.cargo/config.toml b/opennow-streamer/.cargo/config.toml deleted file mode 100644 index 11d8c84f..00000000 --- a/opennow-streamer/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[env] -CMAKE_POLICY_VERSION_MINIMUM = "3.5" diff --git a/opennow-streamer/Cargo.lock b/opennow-streamer/Cargo.lock index e1d7c52b..29594f7f 100644 --- a/opennow-streamer/Cargo.lock +++ b/opennow-streamer/Cargo.lock @@ -2709,6 +2709,7 @@ dependencies = [ "cfg-if", "cmake", "libc", + "pkg-config", "version-compare", ] diff --git a/opennow-streamer/Cargo.toml b/opennow-streamer/Cargo.toml index 4b73a883..ea6999ac 100644 --- a/opennow-streamer/Cargo.toml +++ b/opennow-streamer/Cargo.toml @@ -9,7 +9,6 @@ license = "MIT" anyhow = "1" bytes = "1" opus = "0.3" -sdl2 = { version = "0.38", features = ["bundled"] } clap = { version = "4", features = ["derive"] } env_logger = "0.11" gilrs = "0.11" @@ -23,6 +22,12 @@ webrtc = "0.17" rtp = "0.17" winit = "0.30" +[target.'cfg(target_os = "linux")'.dependencies] +sdl2 = { version = "0.38", features = ["use-pkgconfig"] } + +[target.'cfg(not(target_os = "linux"))'.dependencies] +sdl2 = { version = "0.38", features = ["bundled"] } + [profile.release] lto = true codegen-units = 1 From 6e147a60af095f672c3e57d964ecb7c54a19e60f Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:10:55 +0000 Subject: [PATCH 09/30] fix external streamer startup ordering --- opennow-stable/src/renderer/src/App.tsx | 20 ++++++++++---------- opennow-streamer/src/session.rs | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 26d351b4..7d1ad4a7 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -1560,11 +1560,6 @@ export function App(): JSX.Element { sessionRef.current = claimed; setQueuePosition(undefined); setStreamStatus("connecting"); - await window.openNow.connectSignaling({ - sessionId: claimed.sessionId, - signalingServer: claimed.signalingServer, - signalingUrl: claimed.signalingUrl, - }); if (settings.enableExternalStreamer) { await window.openNow.startExternalStreamer({ session: claimed, @@ -1579,6 +1574,11 @@ export function App(): JSX.Element { }, }); } + await window.openNow.connectSignaling({ + sessionId: claimed.sessionId, + signalingServer: claimed.signalingServer, + signalingUrl: claimed.signalingUrl, + }); }, [authSession, effectiveStreamingBaseUrl, findGameContextForSession, settings]); // Play game handler @@ -1790,11 +1790,6 @@ export function App(): JSX.Element { status: sessionToConnect.status, }); - await window.openNow.connectSignaling({ - sessionId: sessionToConnect.sessionId, - signalingServer: sessionToConnect.signalingServer, - signalingUrl: sessionToConnect.signalingUrl, - }); if (settings.enableExternalStreamer) { await window.openNow.startExternalStreamer({ session: sessionToConnect, @@ -1809,6 +1804,11 @@ export function App(): JSX.Element { }, }); } + await window.openNow.connectSignaling({ + sessionId: sessionToConnect.sessionId, + signalingServer: sessionToConnect.signalingServer, + signalingUrl: sessionToConnect.signalingUrl, + }); } catch (error) { if (launchAbortRef.current) { return; diff --git a/opennow-streamer/src/session.rs b/opennow-streamer/src/session.rs index b3573e6e..4ec02049 100644 --- a/opennow-streamer/src/session.rs +++ b/opennow-streamer/src/session.rs @@ -53,6 +53,17 @@ impl StreamSession { let width = settings.resolution.split('x').next().and_then(|v| v.parse::().ok()).unwrap_or(1920); let height = settings.resolution.split('x').nth(1).and_then(|v| v.parse::().ok()).unwrap_or(1080); let media = MediaPipeline::new(media_tx, control_tx.clone(), VideoSettings { width, height, codec: settings.codec.clone() }); + control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: format!( + "configured native session {} {} {}fps codec={} bitrate={}mbps", + session.session_id, + settings.resolution, + settings.fps, + settings.codec, + settings.max_bitrate_mbps, + ), + }).await.ok(); let control_clone = control_tx.clone(); peer.on_ice_candidate(Box::new(move |candidate| { @@ -104,6 +115,10 @@ impl StreamSession { } pub async fn apply_offer(&self, offer_sdp: String) -> anyhow::Result<()> { + self.control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: format!("applying remote offer ({} chars)", offer_sdp.len()), + }).await.ok(); let partial_reliable = parse_partial_reliable_threshold_ms(&offer_sdp).unwrap_or(30); if self.partially_reliable.lock().await.is_none() { let channel = self.peer.create_data_channel("input_channel_partially_reliable", Some(RTCDataChannelInit { ordered: Some(false), max_packet_life_time: Some(partial_reliable), ..Default::default() })).await?; @@ -131,6 +146,10 @@ impl StreamSession { self.settings.enable_l4s, partial_reliable, ); + self.control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: format!("sending local answer ({} chars) and nvst blob ({} chars)", local.sdp.len(), nvst.len()), + }).await.ok(); self.control_tx.send(StreamerMessage::Answer { sdp: local.sdp.clone(), nvst_sdp: nvst }).await.ok(); if let Some(mci) = &self.session.media_connection_info { From 5d7fb1048030b81a98258b67cdd99b9f95ec44b6 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:16:12 +0000 Subject: [PATCH 10/30] instrument native offer handling --- opennow-streamer/Cargo.lock | 1 + opennow-streamer/Cargo.toml | 1 + opennow-streamer/src/session.rs | 45 ++++++++++++++++++++++++++++----- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/opennow-streamer/Cargo.lock b/opennow-streamer/Cargo.lock index 29594f7f..bc0c1372 100644 --- a/opennow-streamer/Cargo.lock +++ b/opennow-streamer/Cargo.lock @@ -2028,6 +2028,7 @@ dependencies = [ "clap", "env_logger", "gilrs", + "interceptor", "log", "opus", "rtp", diff --git a/opennow-streamer/Cargo.toml b/opennow-streamer/Cargo.toml index ea6999ac..124640f5 100644 --- a/opennow-streamer/Cargo.toml +++ b/opennow-streamer/Cargo.toml @@ -12,6 +12,7 @@ opus = "0.3" clap = { version = "4", features = ["derive"] } env_logger = "0.11" gilrs = "0.11" +interceptor = "0.17.1" log = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/opennow-streamer/src/session.rs b/opennow-streamer/src/session.rs index 4ec02049..f77e7959 100644 --- a/opennow-streamer/src/session.rs +++ b/opennow-streamer/src/session.rs @@ -1,9 +1,10 @@ use std::{sync::{Arc, mpsc::Sender as StdSender}}; use anyhow::{anyhow, Context}; -use tokio::sync::{mpsc, Mutex}; +use interceptor::registry::Registry; +use tokio::{sync::{mpsc, Mutex}, time::{timeout, Duration}}; use webrtc::{ - api::{media_engine::MediaEngine, APIBuilder}, + api::{interceptor_registry::register_default_interceptors, media_engine::MediaEngine, APIBuilder}, data_channel::{data_channel_init::RTCDataChannelInit, RTCDataChannel}, ice_transport::{ice_candidate::RTCIceCandidateInit, ice_server::RTCIceServer}, peer_connection::{configuration::RTCConfiguration, sdp::session_description::RTCSessionDescription, RTCPeerConnection}, @@ -36,7 +37,12 @@ impl StreamSession { ) -> anyhow::Result { let mut media_engine = MediaEngine::default(); media_engine.register_default_codecs().context("register_default_codecs")?; - let api = APIBuilder::new().with_media_engine(media_engine).build(); + let mut registry = Registry::new(); + registry = register_default_interceptors(registry, &mut media_engine).context("register_default_interceptors")?; + let api = APIBuilder::new() + .with_media_engine(media_engine) + .with_interceptor_registry(registry) + .build(); let config = RTCConfiguration { ice_servers: session.ice_servers.iter().map(|server| RTCIceServer { urls: server.urls.clone(), @@ -132,10 +138,37 @@ impl StreamSession { processed = prefer_codec(&processed, &self.settings.codec); let server_ufrag = extract_ice_ufrag_from_offer(&processed); - self.peer.set_remote_description(RTCSessionDescription::offer(processed)?).await?; - let mut answer = self.peer.create_answer(None).await?; + self.control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: format!("setting remote description ({} chars)", processed.len()), + }).await.ok(); + timeout(Duration::from_secs(5), self.peer.set_remote_description(RTCSessionDescription::offer(processed)?)) + .await + .map_err(|_| anyhow!("timed out setting remote description"))??; + self.control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: "remote description applied".into(), + }).await.ok(); + + self.control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: "creating local answer".into(), + }).await.ok(); + let mut answer = timeout(Duration::from_secs(5), self.peer.create_answer(None)) + .await + .map_err(|_| anyhow!("timed out creating answer"))??; answer.sdp = munge_answer_sdp(&answer.sdp, u32::from(self.settings.max_bitrate_mbps) * 1000); - self.peer.set_local_description(answer).await?; + self.control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: format!("setting local description ({} chars)", answer.sdp.len()), + }).await.ok(); + timeout(Duration::from_secs(5), self.peer.set_local_description(answer)) + .await + .map_err(|_| anyhow!("timed out setting local description"))??; + self.control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: "local description applied".into(), + }).await.ok(); let local = self.peer.local_description().await.ok_or_else(|| anyhow!("missing local description"))?; let nvst = build_nvst_sdp( &self.settings.resolution, From 83311b47bbc3df4077e2a67f5615c534889c3825 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:22:17 +0000 Subject: [PATCH 11/30] fix native answer local description flow --- opennow-streamer/src/session.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/opennow-streamer/src/session.rs b/opennow-streamer/src/session.rs index f77e7959..6801cc45 100644 --- a/opennow-streamer/src/session.rs +++ b/opennow-streamer/src/session.rs @@ -154,10 +154,10 @@ impl StreamSession { level: "info".into(), message: "creating local answer".into(), }).await.ok(); - let mut answer = timeout(Duration::from_secs(5), self.peer.create_answer(None)) + let answer = timeout(Duration::from_secs(5), self.peer.create_answer(None)) .await .map_err(|_| anyhow!("timed out creating answer"))??; - answer.sdp = munge_answer_sdp(&answer.sdp, u32::from(self.settings.max_bitrate_mbps) * 1000); + let mut gather_complete = self.peer.gathering_complete_promise().await; self.control_tx.send(StreamerMessage::Log { level: "info".into(), message: format!("setting local description ({} chars)", answer.sdp.len()), @@ -169,7 +169,15 @@ impl StreamSession { level: "info".into(), message: "local description applied".into(), }).await.ok(); + let _ = timeout(Duration::from_secs(5), gather_complete.recv()) + .await + .map_err(|_| anyhow!("timed out waiting for ICE gathering"))?; + self.control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: "ice gathering completed".into(), + }).await.ok(); let local = self.peer.local_description().await.ok_or_else(|| anyhow!("missing local description"))?; + let munged_local_sdp = munge_answer_sdp(&local.sdp, u32::from(self.settings.max_bitrate_mbps) * 1000); let nvst = build_nvst_sdp( &self.settings.resolution, self.settings.fps, @@ -181,9 +189,9 @@ impl StreamSession { ); self.control_tx.send(StreamerMessage::Log { level: "info".into(), - message: format!("sending local answer ({} chars) and nvst blob ({} chars)", local.sdp.len(), nvst.len()), + message: format!("sending local answer ({} chars) and nvst blob ({} chars)", munged_local_sdp.len(), nvst.len()), }).await.ok(); - self.control_tx.send(StreamerMessage::Answer { sdp: local.sdp.clone(), nvst_sdp: nvst }).await.ok(); + self.control_tx.send(StreamerMessage::Answer { sdp: munged_local_sdp, nvst_sdp: nvst }).await.ok(); if let Some(mci) = &self.session.media_connection_info { if let Some(ip) = extract_public_ip(&mci.ip) { From 834783969f346ce620abb40b04f1fdf14ee5af9b Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:24:33 +0000 Subject: [PATCH 12/30] fix native IPC field casing --- .../src/main/services/streamerManager.ts | 10 +++++++++ opennow-streamer/src/messages.rs | 22 ++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/opennow-stable/src/main/services/streamerManager.ts b/opennow-stable/src/main/services/streamerManager.ts index df79495b..c2a4e11a 100644 --- a/opennow-stable/src/main/services/streamerManager.ts +++ b/opennow-stable/src/main/services/streamerManager.ts @@ -305,6 +305,11 @@ export class StreamerManager { } if (parsed.type === "answer" && parsed.sdp) { + this.emit({ + type: "log", + level: "info", + message: `forwarding native answer (${parsed.sdp.length} chars, nvst=${parsed.nvstSdp?.length ?? 0} chars)`, + }); void this.signalingHandlers.sendAnswer({ sdp: parsed.sdp, nvstSdp: parsed.nvstSdp }).catch((error) => { this.emit({ type: "error", message: `Failed to forward native answer: ${String(error)}` }); }); @@ -312,6 +317,11 @@ export class StreamerManager { } if (parsed.type === "local-ice" && parsed.candidate) { + this.emit({ + type: "log", + level: "info", + message: `forwarding native ICE candidate (mid=${parsed.sdpMid ?? "null"}, mline=${parsed.sdpMLineIndex ?? "null"})`, + }); void this.signalingHandlers.sendIceCandidate({ candidate: parsed.candidate, sdpMid: parsed.sdpMid, diff --git a/opennow-streamer/src/messages.rs b/opennow-streamer/src/messages.rs index f6172168..099ea281 100644 --- a/opennow-streamer/src/messages.rs +++ b/opennow-streamer/src/messages.rs @@ -47,7 +47,13 @@ pub struct StreamSettings { pub enum ControlMessage { Configure { session: SessionInfo, settings: StreamSettings }, SignalingOffer { sdp: String }, - SignalingRemoteIce { candidate: String, sdp_mid: Option, sdp_m_line_index: Option }, + SignalingRemoteIce { + candidate: String, + #[serde(rename = "sdpMid")] + sdp_mid: Option, + #[serde(rename = "sdpMLineIndex")] + sdp_m_line_index: Option, + }, Stop, Ping, } @@ -58,8 +64,18 @@ pub enum StreamerMessage { Hello { version: u32, pid: u32 }, Log { level: String, message: String }, State { state: StreamerState, detail: Option }, - Answer { sdp: String, nvst_sdp: String }, - LocalIce { candidate: String, sdp_mid: Option, sdp_m_line_index: Option }, + Answer { + sdp: String, + #[serde(rename = "nvstSdp")] + nvst_sdp: String, + }, + LocalIce { + candidate: String, + #[serde(rename = "sdpMid")] + sdp_mid: Option, + #[serde(rename = "sdpMLineIndex")] + sdp_m_line_index: Option, + }, Error { message: String }, } From 7581bae54f7b67fd56584ad4ddfbf7b5c1ff766e Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:31:16 +0000 Subject: [PATCH 13/30] improve native nvst and ice parity --- opennow-streamer/src/sdp.rs | 169 +++++++++++++++++++++++++++++--- opennow-streamer/src/session.rs | 33 ++++++- 2 files changed, 186 insertions(+), 16 deletions(-) diff --git a/opennow-streamer/src/sdp.rs b/opennow-streamer/src/sdp.rs index 9c515ffe..b0b857bb 100644 --- a/opennow-streamer/src/sdp.rs +++ b/opennow-streamer/src/sdp.rs @@ -44,6 +44,29 @@ pub fn extract_ice_ufrag_from_offer(sdp: &str) -> String { .unwrap_or_default() } +pub struct IceCredentials { + pub ufrag: String, + pub pwd: String, + pub fingerprint: String, +} + +pub fn extract_ice_credentials(sdp: &str) -> IceCredentials { + let mut ufrag = String::new(); + let mut pwd = String::new(); + let mut fingerprint = String::new(); + for line in sdp.lines() { + let trimmed = line.trim(); + if let Some(value) = trimmed.strip_prefix("a=ice-ufrag:") { + ufrag = value.trim().to_string(); + } else if let Some(value) = trimmed.strip_prefix("a=ice-pwd:") { + pwd = value.trim().to_string(); + } else if let Some(value) = trimmed.strip_prefix("a=fingerprint:sha-256 ") { + fingerprint = value.trim().to_string(); + } + } + IceCredentials { ufrag, pwd, fingerprint } +} + pub fn munge_answer_sdp(sdp: &str, max_bitrate_kbps: u32) -> String { let mut out = Vec::new(); let mut current_media = String::new(); @@ -159,26 +182,146 @@ pub fn rewrite_h265_offer(sdp: &str) -> String { pub fn build_nvst_sdp( resolution: &str, + client_viewport_width: u32, + client_viewport_height: u32, fps: u16, max_bitrate_mbps: u16, codec: &str, color_quality: &str, - enable_l4s: bool, partial_reliable_threshold_ms: u16, + credentials: &IceCredentials, ) -> String { let mut parts = resolution.split('x'); let width = parts.next().and_then(|v| v.parse::().ok()).unwrap_or(1920); let height = parts.next().and_then(|v| v.parse::().ok()).unwrap_or(1080); - let bit_depth = if color_quality.starts_with("10bit") { 10 } else { 8 }; - let chroma = if color_quality.ends_with("444") { 444 } else { 420 }; - format!( - "v=0\r\no=OpenNOW 0 0 IN IP4 127.0.0.1\r\ns=OpenNOW NVST\r\nt=0 0\r\na=x-nv-general.featureFlags:0\r\na=x-nv-video[0].clientViewportWd:{width}\r\na=x-nv-video[0].clientViewportHt:{height}\r\na=video.codec:{}\r\na=video.maxFPS:{}\r\na=video.bitDepth:{}\r\na=video.chroma:{}\r\na=bwe.maxBitrateKbps:{}\r\na=vqos.l4s:{}\r\na=ri.partialReliableThresholdMs:{}\r\nm=video 9 RTP/AVP 96\r\na=recvonly\r\nm=audio 9 RTP/AVP 111\r\na=recvonly\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\na=sendrecv\r\n", - codec.to_uppercase(), - fps, - bit_depth, - chroma, - u32::from(max_bitrate_mbps) * 1000, - if enable_l4s { 1 } else { 0 }, - partial_reliable_threshold_ms, - ) + let max_bitrate_kbps = u32::from(max_bitrate_mbps) * 1000; + let min_bitrate = max_bitrate_kbps.max(5000) * 35 / 100; + let initial_bitrate = (max_bitrate_kbps * 70 / 100).max(min_bitrate); + let is_high_fps = fps >= 90; + let is_120_fps = fps == 120; + let is_240_fps = fps >= 240; + let is_av1 = codec.eq_ignore_ascii_case("AV1"); + let bit_depth = if color_quality.starts_with("10bit") && !codec.eq_ignore_ascii_case("H264") { 10 } else { 8 }; + let mut lines = vec![ + "v=0".to_string(), + "o=SdpTest test_id_13 14 IN IPv4 127.0.0.1".to_string(), + "s=-".to_string(), + "t=0 0".to_string(), + format!("a=general.icePassword:{}", credentials.pwd), + format!("a=general.iceUserNameFragment:{}", credentials.ufrag), + format!("a=general.dtlsFingerprint:{}", credentials.fingerprint), + "m=video 0 RTP/AVP".to_string(), + "a=msid:fbc-video-0".to_string(), + "a=vqos.fec.rateDropWindow:10".to_string(), + "a=vqos.fec.minRequiredFecPackets:2".to_string(), + "a=vqos.fec.repairMinPercent:5".to_string(), + "a=vqos.fec.repairPercent:5".to_string(), + "a=vqos.fec.repairMaxPercent:35".to_string(), + "a=vqos.drc.enable:0".to_string(), + "a=vqos.dfc.enable:0".to_string(), + "a=video.dx9EnableNv12:1".to_string(), + "a=video.dx9EnableHdr:1".to_string(), + "a=vqos.qpg.enable:1".to_string(), + "a=vqos.resControl.qp.qpg.featureSetting:7".to_string(), + "a=bwe.useOwdCongestionControl:1".to_string(), + "a=video.enableRtpNack:1".to_string(), + "a=vqos.bw.txRxLag.minFeedbackTxDeltaMs:200".to_string(), + "a=vqos.drc.bitrateIirFilterFactor:18".to_string(), + "a=video.packetSize:1140".to_string(), + "a=packetPacing.minNumPacketsPerGroup:15".to_string(), + ]; + if is_high_fps { + lines.extend([ + "a=bwe.iirFilterFactor:8".to_string(), + "a=video.encoderFeatureSetting:47".to_string(), + "a=video.encoderPreset:6".to_string(), + "a=vqos.resControl.cpmRtc.badNwSkipFramesCount:600".to_string(), + "a=vqos.resControl.cpmRtc.decodeTimeThresholdMs:9".to_string(), + format!("a=video.fbcDynamicFpsGrabTimeoutMs:{}", if is_120_fps { 6 } else { 18 }), + format!("a=vqos.resControl.cpmRtc.serverResolutionUpdateCoolDownCount:{}", if is_120_fps { 6000 } else { 12000 }), + ]); + } + if is_240_fps { + lines.extend([ + "a=video.enableNextCaptureMode:1".to_string(), + "a=vqos.maxStreamFpsEstimate:240".to_string(), + "a=video.videoSplitEncodeStripsPerFrame:3".to_string(), + "a=video.updateSplitEncodeStateDynamically:1".to_string(), + ]); + } + lines.extend([ + "a=vqos.adjustStreamingFpsDuringOutOfFocus:1".to_string(), + "a=vqos.resControl.cpmRtc.ignoreOutOfFocusWindowState:1".to_string(), + "a=vqos.resControl.perfHistory.rtcIgnoreOutOfFocusWindowState:1".to_string(), + "a=vqos.resControl.cpmRtc.featureMask:0".to_string(), + "a=vqos.resControl.cpmRtc.enable:0".to_string(), + "a=vqos.resControl.cpmRtc.minResolutionPercent:100".to_string(), + "a=vqos.resControl.cpmRtc.resolutionChangeHoldonMs:999999".to_string(), + format!("a=packetPacing.numGroups:{}", if is_120_fps { 3 } else { 5 }), + "a=packetPacing.maxDelayUs:1000".to_string(), + "a=packetPacing.minNumPacketsFrame:10".to_string(), + "a=video.rtpNackQueueLength:1024".to_string(), + "a=video.rtpNackQueueMaxPackets:512".to_string(), + "a=video.rtpNackMaxPacketCount:25".to_string(), + "a=vqos.drc.qpMaxResThresholdAdj:4".to_string(), + "a=vqos.grc.qpMaxResThresholdAdj:4".to_string(), + "a=vqos.drc.iirFilterFactor:100".to_string(), + ]); + if is_av1 { + lines.extend([ + "a=vqos.drc.minQpHeadroom:20".to_string(), + "a=vqos.drc.lowerQpThreshold:100".to_string(), + "a=vqos.drc.upperQpThreshold:200".to_string(), + "a=vqos.drc.minAdaptiveQpThreshold:180".to_string(), + "a=vqos.drc.qpCodecThresholdAdj:0".to_string(), + "a=vqos.drc.qpMaxResThresholdAdj:20".to_string(), + "a=vqos.dfc.minQpHeadroom:20".to_string(), + "a=vqos.dfc.qpLowerLimit:100".to_string(), + "a=vqos.dfc.qpMaxUpperLimit:200".to_string(), + "a=vqos.dfc.qpMinUpperLimit:180".to_string(), + "a=vqos.dfc.qpMaxResThresholdAdj:20".to_string(), + "a=vqos.dfc.qpCodecThresholdAdj:0".to_string(), + "a=vqos.grc.minQpHeadroom:20".to_string(), + "a=vqos.grc.lowerQpThreshold:100".to_string(), + "a=vqos.grc.upperQpThreshold:200".to_string(), + "a=vqos.grc.minAdaptiveQpThreshold:180".to_string(), + "a=vqos.grc.qpMaxResThresholdAdj:20".to_string(), + "a=vqos.grc.qpCodecThresholdAdj:0".to_string(), + "a=video.minQp:25".to_string(), + "a=video.enableAv1RcPrecisionFactor:1".to_string(), + ]); + } + lines.extend([ + format!("a=video.clientViewportWd:{client_viewport_width}"), + format!("a=video.clientViewportHt:{client_viewport_height}"), + format!("a=video.maxFPS:{fps}"), + format!("a=video.initialBitrateKbps:{initial_bitrate}"), + format!("a=video.initialPeakBitrateKbps:{max_bitrate_kbps}"), + format!("a=vqos.bw.maximumBitrateKbps:{max_bitrate_kbps}"), + format!("a=vqos.bw.minimumBitrateKbps:{min_bitrate}"), + format!("a=vqos.bw.peakBitrateKbps:{max_bitrate_kbps}"), + format!("a=vqos.bw.serverPeakBitrateKbps:{max_bitrate_kbps}"), + "a=vqos.bw.enableBandwidthEstimation:1".to_string(), + "a=vqos.bw.disableBitrateLimit:0".to_string(), + format!("a=vqos.grc.maximumBitrateKbps:{max_bitrate_kbps}"), + "a=vqos.grc.enable:0".to_string(), + "a=video.maxNumReferenceFrames:4".to_string(), + "a=video.mapRtpTimestampsToFrames:1".to_string(), + "a=video.encoderCscMode:3".to_string(), + "a=video.dynamicRangeMode:0".to_string(), + format!("a=video.bitDepth:{bit_depth}"), + format!("a=video.scalingFeature1:{}", if is_av1 { 1 } else { 0 }), + "a=video.prefilterParams.prefilterModel:0".to_string(), + "m=audio 0 RTP/AVP".to_string(), + "a=msid:audio".to_string(), + "m=mic 0 RTP/AVP".to_string(), + "a=msid:mic".to_string(), + "a=rtpmap:0 PCMU/8000".to_string(), + "m=application 0 RTP/AVP".to_string(), + "a=msid:input_1".to_string(), + format!("a=ri.partialReliableThresholdMs:{partial_reliable_threshold_ms}"), + String::new(), + ]); + let _ = (width, height); + lines.join("\n") } diff --git a/opennow-streamer/src/session.rs b/opennow-streamer/src/session.rs index 6801cc45..85b09f31 100644 --- a/opennow-streamer/src/session.rs +++ b/opennow-streamer/src/session.rs @@ -15,7 +15,7 @@ use crate::{ input, media::{MediaEvent, MediaPipeline, VideoSettings}, messages::{ControlMessage, SessionInfo, StreamSettings, StreamerMessage, StreamerState}, - sdp::{build_nvst_sdp, extract_ice_ufrag_from_offer, fix_server_ip, munge_answer_sdp, parse_partial_reliable_threshold_ms, prefer_codec, rewrite_h265_offer, extract_public_ip}, + sdp::{build_nvst_sdp, extract_ice_credentials, extract_ice_ufrag_from_offer, fix_server_ip, munge_answer_sdp, parse_partial_reliable_threshold_ms, prefer_codec, rewrite_h265_offer, extract_public_ip}, }; pub struct StreamSession { @@ -79,7 +79,7 @@ impl StreamSession { if let Ok(json) = candidate.to_json() { let _ = sender.send(StreamerMessage::LocalIce { candidate: json.candidate, - sdp_mid: json.sdp_mid, + sdp_mid: json.sdp_mid.and_then(|mid| if mid.is_empty() { None } else { Some(mid) }), sdp_m_line_index: json.sdp_mline_index, }).await; } @@ -177,15 +177,29 @@ impl StreamSession { message: "ice gathering completed".into(), }).await.ok(); let local = self.peer.local_description().await.ok_or_else(|| anyhow!("missing local description"))?; + let width = self.settings.resolution.split('x').next().and_then(|v| v.parse::().ok()).unwrap_or(1920); + let height = self.settings.resolution.split('x').nth(1).and_then(|v| v.parse::().ok()).unwrap_or(1080); + let credentials = extract_ice_credentials(&local.sdp); + self.control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: format!( + "local ICE credentials ufrag={} pwd={}... fingerprint={}...", + credentials.ufrag, + credentials.pwd.chars().take(8).collect::(), + credentials.fingerprint.chars().take(20).collect::(), + ), + }).await.ok(); let munged_local_sdp = munge_answer_sdp(&local.sdp, u32::from(self.settings.max_bitrate_mbps) * 1000); let nvst = build_nvst_sdp( &self.settings.resolution, + width, + height, self.settings.fps, self.settings.max_bitrate_mbps, &self.settings.codec, &self.settings.color_quality, - self.settings.enable_l4s, partial_reliable, + &credentials, ); self.control_tx.send(StreamerMessage::Log { level: "info".into(), @@ -196,6 +210,10 @@ impl StreamSession { if let Some(mci) = &self.session.media_connection_info { if let Some(ip) = extract_public_ip(&mci.ip) { let candidate = format!("candidate:1 1 udp 2130706431 {ip} {} typ host", mci.port); + self.control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: format!("injecting manual ICE candidate {candidate}"), + }).await.ok(); for mid in ["0", "1", "2", "3"] { let res = self.peer.add_ice_candidate(RTCIceCandidateInit { candidate: candidate.clone(), @@ -204,7 +222,16 @@ impl StreamSession { username_fragment: Some(server_ufrag.clone()), }).await; if res.is_ok() { + self.control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: format!("manual ICE candidate accepted on sdpMid={mid}"), + }).await.ok(); break; + } else if let Err(error) = res { + self.control_tx.send(StreamerMessage::Log { + level: "warn".into(), + message: format!("manual ICE candidate failed on sdpMid={mid}: {error}"), + }).await.ok(); } } } From ed67b93de03365d069e8d801422d62918ee679d9 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:36:05 +0000 Subject: [PATCH 14/30] log and normalize native ice candidates --- opennow-streamer/src/session.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/opennow-streamer/src/session.rs b/opennow-streamer/src/session.rs index 85b09f31..1500c9ac 100644 --- a/opennow-streamer/src/session.rs +++ b/opennow-streamer/src/session.rs @@ -77,9 +77,11 @@ impl StreamSession { Box::pin(async move { if let Some(candidate) = candidate { if let Ok(json) = candidate.to_json() { + let normalized_mid = json.sdp_mid.and_then(|mid| if mid.is_empty() { None } else { Some(mid) }) + .or_else(|| json.sdp_mline_index.map(|_| "0".to_string())); let _ = sender.send(StreamerMessage::LocalIce { candidate: json.candidate, - sdp_mid: json.sdp_mid.and_then(|mid| if mid.is_empty() { None } else { Some(mid) }), + sdp_mid: normalized_mid, sdp_m_line_index: json.sdp_mline_index, }).await; } @@ -87,6 +89,17 @@ impl StreamSession { }) })); + let control_clone = control_tx.clone(); + peer.on_ice_connection_state_change(Box::new(move |state| { + let sender = control_clone.clone(); + Box::pin(async move { + let _ = sender.send(StreamerMessage::Log { + level: "info".into(), + message: format!("ice connection state {state}"), + }).await; + }) + })); + let control_clone = control_tx.clone(); peer.on_peer_connection_state_change(Box::new(move |state| { let sender = control_clone.clone(); @@ -240,7 +253,21 @@ impl StreamSession { } pub async fn add_remote_ice(&self, candidate: String, sdp_mid: Option, sdp_m_line_index: Option) -> anyhow::Result<()> { - self.peer.add_ice_candidate(RTCIceCandidateInit { candidate, sdp_mid, sdp_mline_index: sdp_m_line_index, username_fragment: None }).await?; + let normalized_mid = sdp_mid.or_else(|| sdp_m_line_index.map(|_| "0".to_string())); + self.control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: format!( + "adding remote ICE candidate (mid={}, mline={})", + normalized_mid.clone().unwrap_or_else(|| "null".into()), + sdp_m_line_index.map(|value| value.to_string()).unwrap_or_else(|| "null".into()), + ), + }).await.ok(); + self.peer.add_ice_candidate(RTCIceCandidateInit { + candidate, + sdp_mid: normalized_mid, + sdp_mline_index: sdp_m_line_index, + username_fragment: None, + }).await?; Ok(()) } From c3b69d538c2959d9c2b4ba95181245e387d593dc Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:45:12 +0000 Subject: [PATCH 15/30] add native transport and track diagnostics --- opennow-streamer/src/session.rs | 86 ++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/opennow-streamer/src/session.rs b/opennow-streamer/src/session.rs index 1500c9ac..199cebd4 100644 --- a/opennow-streamer/src/session.rs +++ b/opennow-streamer/src/session.rs @@ -100,10 +100,36 @@ impl StreamSession { }) })); + let control_clone = control_tx.clone(); + peer.on_signaling_state_change(Box::new(move |state| { + let sender = control_clone.clone(); + Box::pin(async move { + let _ = sender.send(StreamerMessage::Log { + level: "info".into(), + message: format!("signaling state {state}"), + }).await; + }) + })); + + let control_clone = control_tx.clone(); + peer.dtls_transport().on_state_change(Box::new(move |state| { + let sender = control_clone.clone(); + Box::pin(async move { + let _ = sender.send(StreamerMessage::Log { + level: "info".into(), + message: format!("dtls transport state {state}"), + }).await; + }) + })); + let control_clone = control_tx.clone(); peer.on_peer_connection_state_change(Box::new(move |state| { let sender = control_clone.clone(); Box::pin(async move { + let _ = sender.send(StreamerMessage::Log { + level: "info".into(), + message: format!("peer connection state {state}"), + }).await; let mapped = match state.to_string().as_str() { "connected" => StreamerState::Connected, "failed" => StreamerState::Failed, @@ -114,6 +140,30 @@ impl StreamSession { }) })); + let control_clone = control_tx.clone(); + peer.on_data_channel(Box::new(move |channel| { + let sender = control_clone.clone(); + Box::pin(async move { + let label = channel.label().to_string(); + let _ = sender.send(StreamerMessage::Log { + level: "info".into(), + message: format!("remote data channel label={label}"), + }).await; + let open_sender = sender.clone(); + let open_label = label.clone(); + channel.on_open(Box::new(move || { + let sender = open_sender.clone(); + let label = open_label.clone(); + Box::pin(async move { + let _ = sender.send(StreamerMessage::Log { + level: "info".into(), + message: format!("remote data channel opened label={label}"), + }).await; + }) + })); + }) + })); + let media_clone = media.clone(); let control_clone = control_tx.clone(); peer.on_track(Box::new(move |track: Arc, _, _| { @@ -123,9 +173,13 @@ impl StreamSession { let mime = track.codec().capability.mime_type.clone(); let _ = control.send(StreamerMessage::Log { level: "info".into(), message: format!("track {mime}") }).await; if mime.to_lowercase().contains("video") { - let _ = media.attach_video_track(track).await; + if let Err(error) = media.attach_video_track(track).await { + let _ = control.send(StreamerMessage::Error { message: format!("attach video track failed: {error:#}") }).await; + } } else if mime.to_lowercase().contains("audio") { - let _ = media.attach_audio_track(track).await; + if let Err(error) = media.attach_audio_track(track).await { + let _ = control.send(StreamerMessage::Error { message: format!("attach audio track failed: {error:#}") }).await; + } } }) })); @@ -162,6 +216,11 @@ impl StreamSession { level: "info".into(), message: "remote description applied".into(), }).await.ok(); + let transceivers = self.peer.get_transceivers().await; + self.control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: format!("transceivers after remote description: {}", transceivers.len()), + }).await.ok(); self.control_tx.send(StreamerMessage::Log { level: "info".into(), @@ -203,6 +262,29 @@ impl StreamSession { ), }).await.ok(); let munged_local_sdp = munge_answer_sdp(&local.sdp, u32::from(self.settings.max_bitrate_mbps) * 1000); + let negotiated_video_lines = munged_local_sdp + .lines() + .scan(false, |in_video, line| { + if line.starts_with("m=video") { + *in_video = true; + return Some(Some(line.to_string())); + } + if line.starts_with("m=") && *in_video { + *in_video = false; + return Some(None); + } + if *in_video && (line.starts_with("a=rtpmap:") || line.starts_with("a=fmtp:") || line.starts_with("a=rtcp-fb:")) { + return Some(Some(line.to_string())); + } + Some(None) + }) + .flatten() + .collect::>() + .join(" | "); + self.control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: format!("negotiated local video SDP: {negotiated_video_lines}"), + }).await.ok(); let nvst = build_nvst_sdp( &self.settings.resolution, width, From 19128d6d5007a22322a9d6dd30ff52c20845ab5d Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:49:43 +0000 Subject: [PATCH 16/30] force answering dtls role in native streamer --- opennow-streamer/src/session.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/opennow-streamer/src/session.rs b/opennow-streamer/src/session.rs index 199cebd4..73fd84f8 100644 --- a/opennow-streamer/src/session.rs +++ b/opennow-streamer/src/session.rs @@ -4,8 +4,9 @@ use anyhow::{anyhow, Context}; use interceptor::registry::Registry; use tokio::{sync::{mpsc, Mutex}, time::{timeout, Duration}}; use webrtc::{ - api::{interceptor_registry::register_default_interceptors, media_engine::MediaEngine, APIBuilder}, + api::{interceptor_registry::register_default_interceptors, media_engine::MediaEngine, setting_engine::SettingEngine, APIBuilder}, data_channel::{data_channel_init::RTCDataChannelInit, RTCDataChannel}, + dtls_transport::dtls_role::DTLSRole, ice_transport::{ice_candidate::RTCIceCandidateInit, ice_server::RTCIceServer}, peer_connection::{configuration::RTCConfiguration, sdp::session_description::RTCSessionDescription, RTCPeerConnection}, track::track_remote::TrackRemote, @@ -39,8 +40,13 @@ impl StreamSession { media_engine.register_default_codecs().context("register_default_codecs")?; let mut registry = Registry::new(); registry = register_default_interceptors(registry, &mut media_engine).context("register_default_interceptors")?; + let mut setting_engine = SettingEngine::default(); + setting_engine + .set_answering_dtls_role(DTLSRole::Client) + .context("set_answering_dtls_role")?; let api = APIBuilder::new() .with_media_engine(media_engine) + .with_setting_engine(setting_engine) .with_interceptor_registry(registry) .build(); let config = RTCConfiguration { @@ -251,6 +257,12 @@ impl StreamSession { let local = self.peer.local_description().await.ok_or_else(|| anyhow!("missing local description"))?; let width = self.settings.resolution.split('x').next().and_then(|v| v.parse::().ok()).unwrap_or(1920); let height = self.settings.resolution.split('x').nth(1).and_then(|v| v.parse::().ok()).unwrap_or(1080); + if let Some(setup_line) = local.sdp.lines().find(|line| line.trim().starts_with("a=setup:")) { + self.control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: format!("local answer DTLS setup line {setup_line}"), + }).await.ok(); + } let credentials = extract_ice_credentials(&local.sdp); self.control_tx.send(StreamerMessage::Log { level: "info".into(), From 09e0e3f4024aeb878251686fa09e37d28da8ea0d Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:15:28 +0000 Subject: [PATCH 17/30] fix native answer transport SDP --- opennow-streamer/src/sdp.rs | 81 +++++++++++++++++++++++++-------- opennow-streamer/src/session.rs | 21 +++++++-- 2 files changed, 80 insertions(+), 22 deletions(-) diff --git a/opennow-streamer/src/sdp.rs b/opennow-streamer/src/sdp.rs index b0b857bb..44b62135 100644 --- a/opennow-streamer/src/sdp.rs +++ b/opennow-streamer/src/sdp.rs @@ -68,31 +68,76 @@ pub fn extract_ice_credentials(sdp: &str) -> IceCredentials { } pub fn munge_answer_sdp(sdp: &str, max_bitrate_kbps: u32) -> String { + let line_ending = if sdp.contains("\r\n") { "\r\n" } else { "\n" }; let mut out = Vec::new(); - let mut current_media = String::new(); - let mut inserted_bitrate = false; for line in sdp.lines() { - if line.starts_with("m=") { - current_media = line.to_string(); - inserted_bitrate = false; - out.push(line.to_string()); - continue; + out.push(line.to_string()); + if line.starts_with("m=video") { + out.push(format!("b=AS:{max_bitrate_kbps}")); + } else if line.starts_with("m=audio") { + out.push("b=AS:128".to_string()); + } else if line.starts_with("a=fmtp:") && line.contains("minptime=") && !line.contains("stereo=1") { + out.pop(); + out.push(format!("{line};stereo=1")); + } + } + out.join(line_ending) +} + +pub fn normalize_answer_transport_sdp(sdp: &str) -> String { + let line_ending = if sdp.contains("\r\n") { "\r\n" } else { "\n" }; + let session_fingerprint = sdp + .lines() + .find(|line| line.starts_with("a=fingerprint:")) + .map(ToOwned::to_owned); + + let mut result = Vec::new(); + let mut current_media = Vec::new(); + + let flush_media = |result: &mut Vec, current_media: &mut Vec| { + if current_media.is_empty() { + return; } - if !inserted_bitrate && (line.starts_with("c=") || line.starts_with("a=mid:")) { - out.push(line.to_string()); - if current_media.starts_with("m=video") { - out.push(format!("b=AS:{max_bitrate_kbps}")); - inserted_bitrate = true; + let mut setup_seen = false; + let mut fingerprint_seen = false; + let mut normalized = Vec::with_capacity(current_media.len() + 2); + for line in current_media.drain(..) { + if line.starts_with("a=setup:") { + if !setup_seen { + normalized.push("a=setup:active".to_string()); + setup_seen = true; + } + continue; } - continue; + if line.starts_with("a=fingerprint:") { + fingerprint_seen = true; + } + normalized.push(line); } - if line.starts_with("a=fmtp:") && current_media.starts_with("m=audio") && !line.contains("stereo=1") { - out.push(format!("{line};stereo=1;sprop-stereo=1")); - continue; + if !setup_seen { + normalized.push("a=setup:active".to_string()); + } + if !fingerprint_seen { + if let Some(fingerprint) = &session_fingerprint { + normalized.push(fingerprint.clone()); + } + } + result.extend(normalized); + }; + + for line in sdp.lines() { + if line.starts_with("m=") { + flush_media(&mut result, &mut current_media); + } + if current_media.is_empty() && !line.starts_with("m=") { + result.push(line.to_string()); + } else { + current_media.push(line.to_string()); } - out.push(line.to_string()); } - out.join("\r\n") + flush_media(&mut result, &mut current_media); + + result.join(line_ending) } pub fn prefer_codec(sdp: &str, codec: &str) -> String { diff --git a/opennow-streamer/src/session.rs b/opennow-streamer/src/session.rs index 73fd84f8..00362d86 100644 --- a/opennow-streamer/src/session.rs +++ b/opennow-streamer/src/session.rs @@ -16,7 +16,7 @@ use crate::{ input, media::{MediaEvent, MediaPipeline, VideoSettings}, messages::{ControlMessage, SessionInfo, StreamSettings, StreamerMessage, StreamerState}, - sdp::{build_nvst_sdp, extract_ice_credentials, extract_ice_ufrag_from_offer, fix_server_ip, munge_answer_sdp, parse_partial_reliable_threshold_ms, prefer_codec, rewrite_h265_offer, extract_public_ip}, + sdp::{build_nvst_sdp, extract_ice_credentials, extract_ice_ufrag_from_offer, extract_public_ip, fix_server_ip, munge_answer_sdp, normalize_answer_transport_sdp, parse_partial_reliable_threshold_ms, prefer_codec, rewrite_h265_offer}, }; pub struct StreamSession { @@ -44,6 +44,7 @@ impl StreamSession { setting_engine .set_answering_dtls_role(DTLSRole::Client) .context("set_answering_dtls_role")?; + setting_engine.set_sdp_media_level_fingerprints(true); let api = APIBuilder::new() .with_media_engine(media_engine) .with_setting_engine(setting_engine) @@ -274,7 +275,8 @@ impl StreamSession { ), }).await.ok(); let munged_local_sdp = munge_answer_sdp(&local.sdp, u32::from(self.settings.max_bitrate_mbps) * 1000); - let negotiated_video_lines = munged_local_sdp + let normalized_local_sdp = normalize_answer_transport_sdp(&munged_local_sdp); + let negotiated_video_lines = normalized_local_sdp .lines() .scan(false, |in_video, line| { if line.starts_with("m=video") { @@ -297,6 +299,17 @@ impl StreamSession { level: "info".into(), message: format!("negotiated local video SDP: {negotiated_video_lines}"), }).await.ok(); + if let Some(setup_line) = normalized_local_sdp.lines().find(|line| line.trim().starts_with("a=setup:")) { + self.control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: format!("normalized answer DTLS setup line {setup_line}"), + }).await.ok(); + } + let fingerprint_count = normalized_local_sdp.lines().filter(|line| line.trim().starts_with("a=fingerprint:")).count(); + self.control_tx.send(StreamerMessage::Log { + level: "info".into(), + message: format!("normalized answer fingerprint lines {fingerprint_count}"), + }).await.ok(); let nvst = build_nvst_sdp( &self.settings.resolution, width, @@ -310,9 +323,9 @@ impl StreamSession { ); self.control_tx.send(StreamerMessage::Log { level: "info".into(), - message: format!("sending local answer ({} chars) and nvst blob ({} chars)", munged_local_sdp.len(), nvst.len()), + message: format!("sending local answer ({} chars) and nvst blob ({} chars)", normalized_local_sdp.len(), nvst.len()), }).await.ok(); - self.control_tx.send(StreamerMessage::Answer { sdp: munged_local_sdp, nvst_sdp: nvst }).await.ok(); + self.control_tx.send(StreamerMessage::Answer { sdp: normalized_local_sdp, nvst_sdp: nvst }).await.ok(); if let Some(mci) = &self.session.media_connection_info { if let Some(ip) = extract_public_ip(&mci.ip) { From e5de9a5eaef9b403c7793ef5d33fc381a4d1ff09 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:50:05 +0000 Subject: [PATCH 18/30] Improve native streamer protocol parity Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- opennow-streamer/README.md | 10 +- opennow-streamer/src/input.rs | 116 +++++++++++++++++++---- opennow-streamer/src/sdp.rs | 10 ++ opennow-streamer/src/session.rs | 159 +++++++++++++++++++++++++++++--- opennow-streamer/src/window.rs | 98 ++++++++++++++++---- 5 files changed, 337 insertions(+), 56 deletions(-) diff --git a/opennow-streamer/README.md b/opennow-streamer/README.md index 54184195..394580c1 100644 --- a/opennow-streamer/README.md +++ b/opennow-streamer/README.md @@ -17,16 +17,22 @@ Why loopback socket IPC instead of stdio: Current MVP responsibilities implemented here: - native control bridge to Electron main - native WebRTC peer connection, offer handling, answer generation, and ICE forwarding -- GFN-specific SDP handling for server IP fixing, codec filtering, answer munging, NVST SDP generation, and manual media endpoint ICE injection +- GFN-specific SDP handling for server IP fixing, codec filtering, answer munging, NVST SDP generation, native-bundle V2 fingerprint fields, and manual media endpoint ICE injection - native SDL2 play surface for decoded video - native SDL2 audio output for decoded Opus audio -- native keyboard / mouse / controller capture using the existing GFN input packet semantics +- native keyboard / mouse / controller capture using the existing GFN input packet semantics, including protocol-version handshake and gamepad PR framing Current media implementation: - video RTP is depacketized in-process and decoded through an FFmpeg child pipeline into RGB frames rendered in the SDL window - audio RTP is depacketized in-process and decoded with libopus, then queued to SDL audio output - the MVP decode path currently targets the practical GFN desktop path first: H.264 and H.265 video plus Opus audio +Recent native-parity adjustments from the official GFN desktop client: +- dedicated `gamepad_channel_v1` plus `input_channel_v1` / `input_channel_partially_reliable` +- protocol-version detection from the input-channel handshake and native heartbeats on the reliable channel +- gamepad packet bitmap / controller-id / sequence framing aligned with the browser/native protocol observations +- NVST attributes for `general.dtlsFingerprintV2`, `general.icePasswordV2`, `general.iceUserNameFragmentV2`, `general.rtcDataChannelOnNativeBundle`, and related native-bundle flags + Still intentionally out of scope for this phase: - recording / screenshots migration - microphone uplink migration diff --git a/opennow-streamer/src/input.rs b/opennow-streamer/src/input.rs index 3cff9225..7d348435 100644 --- a/opennow-streamer/src/input.rs +++ b/opennow-streamer/src/input.rs @@ -1,20 +1,25 @@ +use std::sync::OnceLock; + use winit::{event::MouseButton, keyboard::KeyCode}; +pub const INPUT_HEARTBEAT: u32 = 2; pub const INPUT_KEY_DOWN: u32 = 3; pub const INPUT_KEY_UP: u32 = 4; pub const INPUT_MOUSE_REL: u32 = 7; pub const INPUT_MOUSE_BUTTON_DOWN: u32 = 8; pub const INPUT_MOUSE_BUTTON_UP: u32 = 9; +pub const INPUT_MOUSE_WHEEL: u32 = 10; pub const INPUT_GAMEPAD: u32 = 12; fn now_micros() -> u64 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); - now.as_micros() as u64 + static START: OnceLock = OnceLock::new(); + START.get_or_init(std::time::Instant::now).elapsed().as_micros() as u64 } -fn wrap_single_event(payload: &[u8]) -> Vec { +fn wrap_single_event(payload: &[u8], protocol_version: u16) -> Vec { + if protocol_version <= 2 { + return payload.to_vec(); + } let mut wrapped = Vec::with_capacity(10 + payload.len()); wrapped.push(0x23); wrapped.extend_from_slice(&now_micros().to_be_bytes()); @@ -23,7 +28,10 @@ fn wrap_single_event(payload: &[u8]) -> Vec { wrapped } -fn wrap_batched_event(payload: &[u8]) -> Vec { +fn wrap_batched_event(payload: &[u8], protocol_version: u16) -> Vec { + if protocol_version <= 2 { + return payload.to_vec(); + } let mut wrapped = Vec::with_capacity(12 + payload.len()); wrapped.push(0x23); wrapped.extend_from_slice(&now_micros().to_be_bytes()); @@ -33,47 +41,115 @@ fn wrap_batched_event(payload: &[u8]) -> Vec { wrapped } -pub fn encode_key(key_code: u16, scancode: u16, modifiers: u16, down: bool) -> Vec { +pub fn encode_heartbeat() -> Vec { + INPUT_HEARTBEAT.to_le_bytes().to_vec() +} + +pub fn encode_key(key_code: u16, scancode: u16, modifiers: u16, down: bool, protocol_version: u16) -> Vec { let mut payload = vec![0_u8; 18]; payload[0..4].copy_from_slice(&(if down { INPUT_KEY_DOWN } else { INPUT_KEY_UP }).to_le_bytes()); payload[4..6].copy_from_slice(&key_code.to_be_bytes()); payload[6..8].copy_from_slice(&modifiers.to_be_bytes()); payload[8..10].copy_from_slice(&scancode.to_be_bytes()); payload[10..18].copy_from_slice(&now_micros().to_be_bytes()); - wrap_single_event(&payload) + wrap_single_event(&payload, protocol_version) } -pub fn encode_mouse_move(dx: i16, dy: i16) -> Vec { +pub fn encode_mouse_move(dx: i16, dy: i16, protocol_version: u16) -> Vec { let mut payload = vec![0_u8; 22]; payload[0..4].copy_from_slice(&INPUT_MOUSE_REL.to_le_bytes()); payload[4..6].copy_from_slice(&dx.to_be_bytes()); payload[6..8].copy_from_slice(&dy.to_be_bytes()); payload[14..22].copy_from_slice(&now_micros().to_be_bytes()); - wrap_batched_event(&payload) + wrap_batched_event(&payload, protocol_version) } -pub fn encode_mouse_button(button: u8, down: bool) -> Vec { +pub fn encode_mouse_button(button: u8, down: bool, protocol_version: u16) -> Vec { let mut payload = vec![0_u8; 18]; payload[0..4].copy_from_slice(&(if down { INPUT_MOUSE_BUTTON_DOWN } else { INPUT_MOUSE_BUTTON_UP }).to_le_bytes()); payload[4] = button; payload[10..18].copy_from_slice(&now_micros().to_be_bytes()); - wrap_single_event(&payload) + wrap_single_event(&payload, protocol_version) +} + +pub fn encode_mouse_wheel(delta: i16, protocol_version: u16) -> Vec { + let mut payload = vec![0_u8; 22]; + payload[0..4].copy_from_slice(&INPUT_MOUSE_WHEEL.to_le_bytes()); + payload[6..8].copy_from_slice(&delta.to_be_bytes()); + payload[14..22].copy_from_slice(&now_micros().to_be_bytes()); + wrap_single_event(&payload, protocol_version) +} + +fn wrap_gamepad_reliable(payload: &[u8], protocol_version: u16) -> Vec { + wrap_batched_event(payload, protocol_version) +} + +fn wrap_gamepad_partially_reliable(payload: &[u8], controller_id: u8, sequence: u16, protocol_version: u16) -> Vec { + if protocol_version <= 2 { + return payload.to_vec(); + } + let mut wrapped = Vec::with_capacity(16 + payload.len()); + wrapped.push(0x23); + wrapped.extend_from_slice(&now_micros().to_be_bytes()); + wrapped.push(0x26); + wrapped.push(controller_id); + wrapped.extend_from_slice(&sequence.to_be_bytes()); + wrapped.push(0x21); + wrapped.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + wrapped.extend_from_slice(payload); + wrapped +} + +pub fn parse_input_handshake(bytes: &[u8]) -> Option { + if bytes.len() < 2 { + return None; + } + let first_word = u16::from_le_bytes([bytes[0], bytes[1]]); + if first_word == 526 { + return Some(if bytes.len() >= 4 { + u16::from_le_bytes([bytes[2], bytes[3]]) + } else { + 2 + }); + } + if bytes[0] == 0x0e { + return Some(first_word); + } + None +} + +pub struct GamepadState { + pub controller_id: u8, + pub bitmap: u16, + pub buttons: u16, + pub left_trigger: u8, + pub right_trigger: u8, + pub left_x: i16, + pub left_y: i16, + pub right_x: i16, + pub right_y: i16, } -pub fn encode_gamepad(buttons: u16, left_trigger: u8, right_trigger: u8, left_x: i16, left_y: i16, right_x: i16, right_y: i16) -> Vec { +pub fn encode_gamepad(state: &GamepadState, use_partially_reliable: bool, sequence: u16, protocol_version: u16) -> Vec { let mut payload = vec![0_u8; 38]; payload[0..4].copy_from_slice(&INPUT_GAMEPAD.to_le_bytes()); payload[4..6].copy_from_slice(&(26_u16).to_le_bytes()); + payload[6..8].copy_from_slice(&(state.controller_id as u16).to_le_bytes()); + payload[8..10].copy_from_slice(&state.bitmap.to_le_bytes()); payload[10..12].copy_from_slice(&(20_u16).to_le_bytes()); - payload[12..14].copy_from_slice(&buttons.to_le_bytes()); - payload[14..16].copy_from_slice(&u16::from_le_bytes([left_trigger, right_trigger]).to_le_bytes()); - payload[16..18].copy_from_slice(&left_x.to_le_bytes()); - payload[18..20].copy_from_slice(&left_y.to_le_bytes()); - payload[20..22].copy_from_slice(&right_x.to_le_bytes()); - payload[22..24].copy_from_slice(&right_y.to_le_bytes()); + payload[12..14].copy_from_slice(&state.buttons.to_le_bytes()); + payload[14..16].copy_from_slice(&u16::from_le_bytes([state.left_trigger, state.right_trigger]).to_le_bytes()); + payload[16..18].copy_from_slice(&state.left_x.to_le_bytes()); + payload[18..20].copy_from_slice(&state.left_y.to_le_bytes()); + payload[20..22].copy_from_slice(&state.right_x.to_le_bytes()); + payload[22..24].copy_from_slice(&state.right_y.to_le_bytes()); payload[26..28].copy_from_slice(&(85_u16).to_le_bytes()); payload[30..38].copy_from_slice(&now_micros().to_le_bytes()); - wrap_batched_event(&payload) + if use_partially_reliable { + wrap_gamepad_partially_reliable(&payload, state.controller_id, sequence, protocol_version) + } else { + wrap_gamepad_reliable(&payload, protocol_version) + } } pub fn key_mapping(code: KeyCode) -> Option<(u16, u16)> { diff --git a/opennow-streamer/src/sdp.rs b/opennow-streamer/src/sdp.rs index 44b62135..f4634ba8 100644 --- a/opennow-streamer/src/sdp.rs +++ b/opennow-streamer/src/sdp.rs @@ -253,8 +253,16 @@ pub fn build_nvst_sdp( "s=-".to_string(), "t=0 0".to_string(), format!("a=general.icePassword:{}", credentials.pwd), + format!("a=general.icePasswordV2:{}", credentials.pwd), format!("a=general.iceUserNameFragment:{}", credentials.ufrag), + format!("a=general.iceUserNameFragmentV2:{}", credentials.ufrag), format!("a=general.dtlsFingerprint:{}", credentials.fingerprint), + format!("a=general.dtlsFingerprintV2:{}", credentials.fingerprint), + "a=general.rtcDataChannelOnNativeBundle:1".to_string(), + "a=general.rtcAudioOnNativeBundle:1".to_string(), + "a=general.rtcMicOnNativeBundle:1".to_string(), + "a=general.rtcVideoOnNativeBundle:1".to_string(), + "a=general.rtcpOnSctp:1".to_string(), "m=video 0 RTP/AVP".to_string(), "a=msid:fbc-video-0".to_string(), "a=vqos.fec.rateDropWindow:10".to_string(), @@ -365,6 +373,8 @@ pub fn build_nvst_sdp( "m=application 0 RTP/AVP".to_string(), "a=msid:input_1".to_string(), format!("a=ri.partialReliableThresholdMs:{partial_reliable_threshold_ms}"), + "a=ri.enablePartiallyReliableTransferHid:1".to_string(), + "a=ri.enablePartiallyReliableTransferGamepad:1".to_string(), String::new(), ]); let _ = (width, height); diff --git a/opennow-streamer/src/session.rs b/opennow-streamer/src/session.rs index 00362d86..cd0ea12e 100644 --- a/opennow-streamer/src/session.rs +++ b/opennow-streamer/src/session.rs @@ -1,4 +1,4 @@ -use std::{sync::{Arc, mpsc::Sender as StdSender}}; +use std::{collections::HashMap, sync::{atomic::{AtomicBool, AtomicU16, Ordering}, Arc, mpsc::Sender as StdSender}}; use anyhow::{anyhow, Context}; use interceptor::registry::Registry; @@ -22,7 +22,10 @@ use crate::{ pub struct StreamSession { peer: Arc, reliable: Arc, - partially_reliable: Arc>>>, + hid_partially_reliable: Arc>>>, + gamepad_channel: Arc>>>, + input_protocol_version: Arc, + gamepad_sequence: Arc>>, control_tx: mpsc::Sender, session: SessionInfo, settings: StreamSettings, @@ -61,7 +64,10 @@ impl StreamSession { }; let peer = Arc::new(api.new_peer_connection(config).await.context("new_peer_connection")?); let reliable = peer.create_data_channel("input_channel_v1", Some(RTCDataChannelInit { ordered: Some(true), ..Default::default() })).await?; - let partially_reliable = Arc::new(Mutex::new(None)); + let hid_partially_reliable = Arc::new(Mutex::new(None)); + let gamepad_channel = Arc::new(Mutex::new(None)); + let input_protocol_version = Arc::new(AtomicU16::new(2)); + let gamepad_sequence = Arc::new(Mutex::new(HashMap::new())); let width = settings.resolution.split('x').next().and_then(|v| v.parse::().ok()).unwrap_or(1920); let height = settings.resolution.split('x').nth(1).and_then(|v| v.parse::().ok()).unwrap_or(1080); @@ -75,9 +81,61 @@ impl StreamSession { settings.fps, settings.codec, settings.max_bitrate_mbps, - ), + ), }).await.ok(); + let handshake_log_tx = control_tx.clone(); + let handshake_channel = reliable.clone(); + let handshake_protocol_version = input_protocol_version.clone(); + let heartbeat_started = Arc::new(AtomicBool::new(false)); + let heartbeat_started_for_handler = heartbeat_started.clone(); + reliable.on_message(Box::new(move |message| { + let sender = handshake_log_tx.clone(); + let channel = handshake_channel.clone(); + let protocol_version = handshake_protocol_version.clone(); + let heartbeat_started = heartbeat_started_for_handler.clone(); + Box::pin(async move { + if let Some(version) = input::parse_input_handshake(message.data.as_ref()) { + protocol_version.store(version, Ordering::Relaxed); + let _ = sender.send(StreamerMessage::Log { + level: "info".into(), + message: format!("input handshake complete (protocol v{version})"), + }).await; + if !heartbeat_started.swap(true, Ordering::SeqCst) { + let sender = sender.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(2)); + loop { + interval.tick().await; + if channel.ready_state().to_string().as_str() != "open" { + break; + } + if channel.send(&input::encode_heartbeat().into()).await.is_err() { + break; + } + } + let _ = sender.send(StreamerMessage::Log { + level: "debug".into(), + message: "input heartbeat loop stopped".into(), + }).await; + }); + } + return; + } + let hex = message + .data + .iter() + .take(16) + .map(|byte| format!("{byte:02x}")) + .collect::>() + .join(" "); + let _ = sender.send(StreamerMessage::Log { + level: "debug".into(), + message: format!("input channel message {} bytes [{}]", message.data.len(), hex), + }).await; + }) + })); + let control_clone = control_tx.clone(); peer.on_ice_candidate(Box::new(move |candidate| { let sender = control_clone.clone(); @@ -168,6 +226,33 @@ impl StreamSession { }).await; }) })); + let message_sender = sender.clone(); + let message_label = label.clone(); + channel.on_message(Box::new(move |message| { + let sender = message_sender.clone(); + let label = message_label.clone(); + Box::pin(async move { + if message.is_string { + let text = String::from_utf8_lossy(message.data.as_ref()).into_owned(); + let _ = sender.send(StreamerMessage::Log { + level: "info".into(), + message: format!("remote data channel {label}: {text}"), + }).await; + return; + } + let hex = message + .data + .iter() + .take(16) + .map(|byte| format!("{byte:02x}")) + .collect::>() + .join(" "); + let _ = sender.send(StreamerMessage::Log { + level: "debug".into(), + message: format!("remote data channel {label}: {} bytes [{}]", message.data.len(), hex), + }).await; + }) + })); }) })); @@ -191,7 +276,18 @@ impl StreamSession { }) })); - Ok(Self { peer, reliable, partially_reliable, control_tx, session, settings, media }) + Ok(Self { + peer, + reliable, + hid_partially_reliable, + gamepad_channel, + input_protocol_version, + gamepad_sequence, + control_tx, + session, + settings, + media, + }) } pub async fn apply_offer(&self, offer_sdp: String) -> anyhow::Result<()> { @@ -200,9 +296,19 @@ impl StreamSession { message: format!("applying remote offer ({} chars)", offer_sdp.len()), }).await.ok(); let partial_reliable = parse_partial_reliable_threshold_ms(&offer_sdp).unwrap_or(30); - if self.partially_reliable.lock().await.is_none() { - let channel = self.peer.create_data_channel("input_channel_partially_reliable", Some(RTCDataChannelInit { ordered: Some(false), max_packet_life_time: Some(partial_reliable), ..Default::default() })).await?; - *self.partially_reliable.lock().await = Some(channel); + if self.hid_partially_reliable.lock().await.is_none() { + let channel = self.peer.create_data_channel( + "input_channel_partially_reliable", + Some(RTCDataChannelInit { ordered: Some(false), max_packet_life_time: Some(partial_reliable), ..Default::default() }), + ).await?; + *self.hid_partially_reliable.lock().await = Some(channel); + } + if self.gamepad_channel.lock().await.is_none() { + let channel = self.peer.create_data_channel( + "gamepad_channel_v1", + Some(RTCDataChannelInit { ordered: Some(false), max_packet_life_time: Some(partial_reliable), ..Default::default() }), + ).await?; + *self.gamepad_channel.lock().await = Some(channel); } let server_ip_for_sdp = self.session.media_connection_info.as_ref().map(|m| m.ip.as_str()).unwrap_or(self.session.server_ip.as_str()); let mut processed = fix_server_ip(&offer_sdp, server_ip_for_sdp); @@ -381,20 +487,42 @@ impl StreamSession { pub async fn send_input(&self, payload: InputPayload) { match payload { InputPayload::Key { key_code, scan_code, modifiers, down } => { - let bytes = input::encode_key(key_code, scan_code, modifiers, down); + let bytes = input::encode_key(key_code, scan_code, modifiers, down, self.input_protocol_version.load(Ordering::Relaxed)); let _ = self.reliable.send(&bytes.into()).await; } InputPayload::MouseMove { dx, dy } => { - let bytes = input::encode_mouse_move(dx, dy); + let bytes = input::encode_mouse_move(dx, dy, self.input_protocol_version.load(Ordering::Relaxed)); let _ = self.reliable.send(&bytes.into()).await; } InputPayload::MouseButton { button, down } => { - let bytes = input::encode_mouse_button(button, down); + let bytes = input::encode_mouse_button(button, down, self.input_protocol_version.load(Ordering::Relaxed)); + let _ = self.reliable.send(&bytes.into()).await; + } + InputPayload::MouseWheel { delta } => { + let bytes = input::encode_mouse_wheel(delta, self.input_protocol_version.load(Ordering::Relaxed)); let _ = self.reliable.send(&bytes.into()).await; } - InputPayload::Gamepad { buttons, left_trigger, right_trigger, left_x, left_y, right_x, right_y } => { - let bytes = input::encode_gamepad(buttons, left_trigger, right_trigger, left_x, left_y, right_x, right_y); - if let Some(channel) = self.partially_reliable.lock().await.clone() { + InputPayload::Gamepad { controller_id, bitmap, buttons, left_trigger, right_trigger, left_x, left_y, right_x, right_y } => { + let protocol_version = self.input_protocol_version.load(Ordering::Relaxed); + let use_partially_reliable = self.gamepad_channel.lock().await.is_some(); + let sequence = { + let mut sequences = self.gamepad_sequence.lock().await; + let current = sequences.get(&controller_id).copied().unwrap_or(1); + sequences.insert(controller_id, current.wrapping_add(1)); + current + }; + let bytes = input::encode_gamepad(&input::GamepadState { + controller_id, + bitmap, + buttons, + left_trigger, + right_trigger, + left_x, + left_y, + right_x, + right_y, + }, use_partially_reliable, sequence, protocol_version); + if let Some(channel) = self.gamepad_channel.lock().await.clone() { let _ = channel.send(&bytes.into()).await; } else { let _ = self.reliable.send(&bytes.into()).await; @@ -413,7 +541,8 @@ pub enum InputPayload { Key { key_code: u16, scan_code: u16, modifiers: u16, down: bool }, MouseMove { dx: i16, dy: i16 }, MouseButton { button: u8, down: bool }, - Gamepad { buttons: u16, left_trigger: u8, right_trigger: u8, left_x: i16, left_y: i16, right_x: i16, right_y: i16 }, + MouseWheel { delta: i16 }, + Gamepad { controller_id: u8, bitmap: u16, buttons: u16, left_trigger: u8, right_trigger: u8, left_x: i16, left_y: i16, right_x: i16, right_y: i16 }, } pub type SharedSession = Arc>>>; diff --git a/opennow-streamer/src/window.rs b/opennow-streamer/src/window.rs index 646ad16b..cbfc419a 100644 --- a/opennow-streamer/src/window.rs +++ b/opennow-streamer/src/window.rs @@ -1,4 +1,4 @@ -use std::{sync::mpsc::Receiver, time::Duration}; +use std::{collections::{HashMap, HashSet}, sync::mpsc::Receiver, time::Duration}; use tokio::sync::mpsc::UnboundedSender; @@ -24,13 +24,12 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un let video = sdl.video().map_err(|e| anyhow::anyhow!(e)).context("sdl video")?; let audio = sdl.audio().map_err(|e| anyhow::anyhow!(e)).context("sdl audio")?; let game_controller = sdl.game_controller().ok(); - let mut opened_controller = None; + let mut opened_controllers: HashMap = HashMap::new(); if let Some(gc) = &game_controller { for idx in 0_u32..gc.num_joysticks().unwrap_or(0) { if gc.is_game_controller(idx) { if let Ok(controller) = gc.open(idx) { - opened_controller = Some(controller); - break; + opened_controllers.insert(controller.instance_id(), controller); } } } @@ -56,6 +55,7 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un let mut event_pump = sdl.event_pump().map_err(|e| anyhow::anyhow!(e)).context("event pump")?; let mut latest_frame: Option = None; + let mut connected_slots = HashSet::::new(); let mut running = true; while running { while let Ok(event) = media_rx.try_recv() { @@ -116,21 +116,34 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un send_input(&input_tx, InputPayload::MouseButton { button, down: false }); } } - Event::ControllerAxisMotion { .. } - | Event::ControllerButtonDown { .. } - | Event::ControllerButtonUp { .. } - | Event::ControllerDeviceAdded { .. } - | Event::ControllerDeviceRemoved { .. } => { - if let Some(controller) = opened_controller.as_ref() { - send_input(&input_tx, InputPayload::Gamepad { - buttons: map_controller_buttons(controller), - left_trigger: axis_to_u8(controller.axis(sdl2::controller::Axis::TriggerLeft)), - right_trigger: axis_to_u8(controller.axis(sdl2::controller::Axis::TriggerRight)), - left_x: controller.axis(sdl2::controller::Axis::LeftX), - left_y: -controller.axis(sdl2::controller::Axis::LeftY), - right_x: controller.axis(sdl2::controller::Axis::RightX), - right_y: -controller.axis(sdl2::controller::Axis::RightY), - }); + Event::MouseWheel { y, .. } => { + let delta = (-y).clamp(i16::MIN as i32, i16::MAX as i32) as i16; + send_input(&input_tx, InputPayload::MouseWheel { delta }); + } + Event::ControllerDeviceAdded { which, .. } => { + if let Some(gc) = &game_controller { + if let Ok(controller) = gc.open(which) { + let instance_id = controller.instance_id(); + opened_controllers.insert(instance_id, controller); + let controller_id = slot_for_instance(instance_id, &opened_controllers); + connected_slots.insert(controller_id); + send_controller_state(&input_tx, &opened_controllers, controller_id, Some(instance_id), &connected_slots); + } + } + } + Event::ControllerDeviceRemoved { which, .. } => { + let controller_id = slot_for_instance(which, &opened_controllers); + opened_controllers.remove(&which); + connected_slots.remove(&controller_id); + send_controller_state(&input_tx, &opened_controllers, controller_id, None, &connected_slots); + } + Event::ControllerAxisMotion { which, .. } + | Event::ControllerButtonDown { which, .. } + | Event::ControllerButtonUp { which, .. } => { + if opened_controllers.contains_key(&which) { + let controller_id = slot_for_instance(which, &opened_controllers); + connected_slots.insert(controller_id); + send_controller_state(&input_tx, &opened_controllers, controller_id, Some(which), &connected_slots); } } _ => {} @@ -146,6 +159,53 @@ fn send_input(input_tx: &UnboundedSender, payload: InputPayload) { let _ = input_tx.send(payload); } +fn send_controller_state( + input_tx: &UnboundedSender, + controllers: &HashMap, + controller_id: u8, + instance_id: Option, + connected_slots: &HashSet, +) { + let bitmap = connected_slots.iter().fold(0_u16, |bitmap, slot| bitmap | (1_u16 << slot)); + if let Some(instance_id) = instance_id { + if let Some(controller) = controllers.get(&instance_id) { + send_input(input_tx, InputPayload::Gamepad { + controller_id, + bitmap, + buttons: map_controller_buttons(controller), + left_trigger: axis_to_u8(controller.axis(sdl2::controller::Axis::TriggerLeft)), + right_trigger: axis_to_u8(controller.axis(sdl2::controller::Axis::TriggerRight)), + left_x: controller.axis(sdl2::controller::Axis::LeftX), + left_y: -controller.axis(sdl2::controller::Axis::LeftY), + right_x: controller.axis(sdl2::controller::Axis::RightX), + right_y: -controller.axis(sdl2::controller::Axis::RightY), + }); + return; + } + } + send_input(input_tx, InputPayload::Gamepad { + controller_id, + bitmap, + buttons: 0, + left_trigger: 0, + right_trigger: 0, + left_x: 0, + left_y: 0, + right_x: 0, + right_y: 0, + }); +} + +fn slot_for_instance(instance_id: u32, controllers: &HashMap) -> u8 { + let mut sorted = controllers.keys().copied().collect::>(); + sorted.sort_unstable(); + sorted + .iter() + .position(|value| *value == instance_id) + .unwrap_or(0) + .min(3) as u8 +} + fn queue_audio(queue: &AudioQueue, frame: AudioFrame) { let _ = (frame.channels, frame.sample_rate); let _ = queue.queue_audio(&frame.samples); From b4deccaef8837eaa67f4eba5408828bc1b27347f Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:58:54 +0000 Subject: [PATCH 19/30] Improve native mouse capture behavior Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- opennow-streamer/src/window.rs | 66 ++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/opennow-streamer/src/window.rs b/opennow-streamer/src/window.rs index cbfc419a..209baa14 100644 --- a/opennow-streamer/src/window.rs +++ b/opennow-streamer/src/window.rs @@ -7,10 +7,13 @@ use sdl2::{ audio::{AudioQueue, AudioSpecDesired}, controller::Button as ControllerButton, event::Event, + hint, keyboard::{Mod, Scancode}, + mouse::MouseUtil, pixels::PixelFormatEnum, rect::Rect, - render::TextureCreator, + render::{Canvas, TextureCreator}, + video::Window, video::WindowContext, }; @@ -20,9 +23,19 @@ use crate::{ }; pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: UnboundedSender, width: u32, height: u32) -> anyhow::Result<()> { + hint::set("SDL_MOUSE_AUTO_CAPTURE", "0"); + hint::set("SDL_MOUSE_FOCUS_CLICKTHROUGH", "1"); + hint::set("SDL_MOUSE_RELATIVE_MODE_WARP", "0"); + hint::set("SDL_MOUSE_RELATIVE_WARP_MOTION", "0"); + hint::set("SDL_MOUSE_RELATIVE_SYSTEM_SCALE", "0"); + hint::set("SDL_MOUSE_RELATIVE_SPEED_SCALE", "1"); + hint::set("SDL_MOUSE_TOUCH_EVENTS", "0"); + hint::set_video_minimize_on_focus_loss(false); + let sdl = sdl2::init().map_err(|e| anyhow::anyhow!(e)).context("sdl init")?; let video = sdl.video().map_err(|e| anyhow::anyhow!(e)).context("sdl video")?; let audio = sdl.audio().map_err(|e| anyhow::anyhow!(e)).context("sdl audio")?; + let mouse = sdl.mouse(); let game_controller = sdl.game_controller().ok(); let mut opened_controllers: HashMap = HashMap::new(); if let Some(gc) = &game_controller { @@ -56,6 +69,7 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un let mut event_pump = sdl.event_pump().map_err(|e| anyhow::anyhow!(e)).context("event pump")?; let mut latest_frame: Option = None; let mut connected_slots = HashSet::::new(); + let mut mouse_captured = false; let mut running = true; while running { while let Ok(event) = media_rx.try_recv() { @@ -77,6 +91,28 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un for event in event_pump.poll_iter() { match event { Event::Quit { .. } => running = false, + Event::Window { win_event, .. } => match win_event { + sdl2::event::WindowEvent::FocusLost | sdl2::event::WindowEvent::Leave | sdl2::event::WindowEvent::Close => { + if mouse_captured { + set_mouse_capture(&mouse, &mut canvas, false); + mouse_captured = false; + } + } + _ => {} + }, + Event::KeyDown { scancode: Some(Scancode::Escape), repeat: false, .. } => { + if mouse_captured { + set_mouse_capture(&mouse, &mut canvas, false); + mouse_captured = false; + } else if let Some((vk, code)) = map_scancode(Scancode::Escape) { + send_input(&input_tx, InputPayload::Key { + key_code: vk, + scan_code: code, + modifiers: 0, + down: true, + }); + } + } Event::KeyDown { scancode: Some(scancode), keymod, repeat, .. } => { if !repeat { if let Some((vk, code)) = map_scancode(scancode) { @@ -102,23 +138,31 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un } } Event::MouseMotion { xrel, yrel, .. } => { - if xrel != 0 || yrel != 0 { + if mouse_captured && (xrel != 0 || yrel != 0) { send_input(&input_tx, InputPayload::MouseMove { dx: xrel.clamp(i16::MIN as i32, i16::MAX as i32) as i16, dy: yrel.clamp(i16::MIN as i32, i16::MAX as i32) as i16 }); } } Event::MouseButtonDown { mouse_btn, .. } => { + if !mouse_captured { + set_mouse_capture(&mouse, &mut canvas, true); + mouse_captured = true; + } if let Some(button) = map_mouse_button(mouse_btn) { send_input(&input_tx, InputPayload::MouseButton { button, down: true }); } } Event::MouseButtonUp { mouse_btn, .. } => { - if let Some(button) = map_mouse_button(mouse_btn) { - send_input(&input_tx, InputPayload::MouseButton { button, down: false }); + if mouse_captured { + if let Some(button) = map_mouse_button(mouse_btn) { + send_input(&input_tx, InputPayload::MouseButton { button, down: false }); + } } } Event::MouseWheel { y, .. } => { - let delta = (-y).clamp(i16::MIN as i32, i16::MAX as i32) as i16; - send_input(&input_tx, InputPayload::MouseWheel { delta }); + if mouse_captured { + let delta = (-y).clamp(i16::MIN as i32, i16::MAX as i32) as i16; + send_input(&input_tx, InputPayload::MouseWheel { delta }); + } } Event::ControllerDeviceAdded { which, .. } => { if let Some(gc) = &game_controller { @@ -155,6 +199,16 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un Ok(()) } +fn set_mouse_capture(mouse: &MouseUtil, canvas: &mut Canvas, captured: bool) { + mouse.capture(captured); + mouse.set_relative_mouse_mode(captured); + mouse.show_cursor(!captured); + let window = canvas.window_mut(); + window.set_grab(captured); + window.set_mouse_grab(captured); + window.set_keyboard_grab(captured); +} + fn send_input(input_tx: &UnboundedSender, payload: InputPayload) { let _ = input_tx.send(payload); } From a863058d45df84c22c6a5201e11e5e2d05bf01f9 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:07:25 +0000 Subject: [PATCH 20/30] Reduce native decode latency on Linux Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- opennow-streamer/src/media.rs | 46 +++++++++++++++++++++++++--------- opennow-streamer/src/window.rs | 21 ++++++++++++---- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/opennow-streamer/src/media.rs b/opennow-streamer/src/media.rs index d855c6ff..7b9e4aa2 100644 --- a/opennow-streamer/src/media.rs +++ b/opennow-streamer/src/media.rs @@ -22,7 +22,9 @@ use crate::messages::StreamerMessage; pub struct VideoFrame { pub width: u32, pub height: u32, - pub pixels: Vec, + pub y_plane: Vec, + pub u_plane: Vec, + pub v_plane: Vec, } #[derive(Clone)] @@ -170,31 +172,51 @@ impl FfmpegVideoDecoder { log_tx: tokio::sync::mpsc::Sender, ) -> anyhow::Result { let ffmpeg = resolve_ffmpeg_binary()?; - let mut child = Command::new(ffmpeg) + let mut command = Command::new(ffmpeg); + command.args([ + "-loglevel", "warning", + "-fflags", "nobuffer", + "-flags", "low_delay", + "-flags2", "fast", + "-probesize", "32", + "-analyzeduration", "0", + "-thread_queue_size", "4", + ]); + #[cfg(target_os = "linux")] + command.args([ + "-hwaccel", "auto", + "-extra_hw_frames", "8", + ]); + command .args([ - "-loglevel", "error", - "-fflags", "nobuffer", - "-flags", "low_delay", - "-probesize", "32", - "-analyzeduration", "0", "-f", demuxer, "-i", "pipe:0", + "-vf", "setpts=PTS-STARTPTS", + "-pix_fmt", "yuv420p", "-f", "rawvideo", - "-pix_fmt", "rgb24", "pipe:1", ]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .spawn() - .context("spawn ffmpeg video decoder")?; - let frame_size = (width * height * 3) as usize; + ; + let mut child = command.spawn().context("spawn ffmpeg video decoder")?; + let y_size = (width * height) as usize; + let uv_size = ((width / 2) * (height / 2)) as usize; + let frame_size = y_size + uv_size + uv_size; let mut stdout = child.stdout.take().ok_or_else(|| anyhow!("missing ffmpeg stdout"))?; let mut stderr = child.stderr.take().ok_or_else(|| anyhow!("missing ffmpeg stderr"))?; + let _ = log_tx.blocking_send(StreamerMessage::Log { + level: "info".into(), + message: format!("spawned ffmpeg decoder for {demuxer} {}x{} (linux_hwaccel={})", width, height, cfg!(target_os = "linux")), + }); thread::spawn(move || { let mut buffer = vec![0_u8; frame_size]; while stdout.read_exact(&mut buffer).is_ok() { - let _ = event_tx.send(MediaEvent::Video(VideoFrame { width, height, pixels: buffer.clone() })); + let y_plane = buffer[..y_size].to_vec(); + let u_plane = buffer[y_size..(y_size + uv_size)].to_vec(); + let v_plane = buffer[(y_size + uv_size)..].to_vec(); + let _ = event_tx.send(MediaEvent::Video(VideoFrame { width, height, y_plane, u_plane, v_plane })); } }); thread::spawn(move || { diff --git a/opennow-streamer/src/window.rs b/opennow-streamer/src/window.rs index 209baa14..5ea5f41e 100644 --- a/opennow-streamer/src/window.rs +++ b/opennow-streamer/src/window.rs @@ -12,7 +12,7 @@ use sdl2::{ mouse::MouseUtil, pixels::PixelFormatEnum, rect::Rect, - render::{Canvas, TextureCreator}, + render::{BlendMode, Canvas, TextureCreator}, video::Window, video::WindowContext, }; @@ -54,11 +54,12 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un .resizable() .build() .context("create SDL window")?; - let mut canvas = window.into_canvas().accelerated().present_vsync().build().context("create SDL canvas")?; + let mut canvas = window.into_canvas().accelerated().build().context("create SDL canvas")?; let texture_creator: TextureCreator = canvas.texture_creator(); let mut texture = texture_creator - .create_texture_streaming(PixelFormatEnum::RGB24, width, height) + .create_texture_streaming(PixelFormatEnum::IYUV, width, height) .context("create texture")?; + texture.set_blend_mode(BlendMode::None); let queue: AudioQueue = audio .open_queue::(None, &AudioSpecDesired { freq: Some(48_000), channels: Some(2), samples: Some(1024) }) @@ -80,7 +81,17 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un } if let Some(frame) = latest_frame.take() { - texture.update(None, &frame.pixels, (frame.width * 3) as usize).context("texture update")?; + texture + .update_yuv( + None, + &frame.y_plane, + frame.width as usize, + &frame.u_plane, + (frame.width / 2) as usize, + &frame.v_plane, + (frame.width / 2) as usize, + ) + .context("texture update yuv")?; } canvas.clear(); @@ -193,7 +204,7 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un _ => {} } } - std::thread::sleep(Duration::from_millis(4)); + std::thread::sleep(Duration::from_millis(1)); } Ok(()) From 98248be9dff27fd25d8f390f3730d570aea68f1e Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:34:20 +0000 Subject: [PATCH 21/30] Add native decoder backend fallback ladder Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- opennow-streamer/src/media.rs | 393 +++++++++++++++++++++++++++++----- 1 file changed, 335 insertions(+), 58 deletions(-) diff --git a/opennow-streamer/src/media.rs b/opennow-streamer/src/media.rs index 7b9e4aa2..a2e88eb0 100644 --- a/opennow-streamer/src/media.rs +++ b/opennow-streamer/src/media.rs @@ -1,8 +1,9 @@ use std::{ + collections::HashSet, env, io::{Read, Write}, path::{Path, PathBuf}, - process::{Child, ChildStdin, Command, Stdio}, + process::{Child, ChildStdin, Command, ExitStatus, Stdio}, sync::{mpsc::Sender, Arc}, thread, }; @@ -159,8 +160,16 @@ async fn run_audio_track( } struct FfmpegVideoDecoder { + ffmpeg: PathBuf, + demuxer: String, + width: u32, + height: u32, + event_tx: Sender, + log_tx: tokio::sync::mpsc::Sender, + candidates: Vec, + current_index: usize, stdin: ChildStdin, - _child: Child, + child: Child, } impl FfmpegVideoDecoder { @@ -172,67 +181,335 @@ impl FfmpegVideoDecoder { log_tx: tokio::sync::mpsc::Sender, ) -> anyhow::Result { let ffmpeg = resolve_ffmpeg_binary()?; - let mut command = Command::new(ffmpeg); - command.args([ - "-loglevel", "warning", - "-fflags", "nobuffer", - "-flags", "low_delay", - "-flags2", "fast", - "-probesize", "32", - "-analyzeduration", "0", - "-thread_queue_size", "4", - ]); - #[cfg(target_os = "linux")] - command.args([ - "-hwaccel", "auto", - "-extra_hw_frames", "8", - ]); - command - .args([ - "-f", demuxer, - "-i", "pipe:0", - "-vf", "setpts=PTS-STARTPTS", - "-pix_fmt", "yuv420p", - "-f", "rawvideo", - "pipe:1", - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - ; - let mut child = command.spawn().context("spawn ffmpeg video decoder")?; - let y_size = (width * height) as usize; - let uv_size = ((width / 2) * (height / 2)) as usize; - let frame_size = y_size + uv_size + uv_size; - let mut stdout = child.stdout.take().ok_or_else(|| anyhow!("missing ffmpeg stdout"))?; - let mut stderr = child.stderr.take().ok_or_else(|| anyhow!("missing ffmpeg stderr"))?; - let _ = log_tx.blocking_send(StreamerMessage::Log { - level: "info".into(), - message: format!("spawned ffmpeg decoder for {demuxer} {}x{} (linux_hwaccel={})", width, height, cfg!(target_os = "linux")), - }); - thread::spawn(move || { - let mut buffer = vec![0_u8; frame_size]; - while stdout.read_exact(&mut buffer).is_ok() { - let y_plane = buffer[..y_size].to_vec(); - let u_plane = buffer[y_size..(y_size + uv_size)].to_vec(); - let v_plane = buffer[(y_size + uv_size)..].to_vec(); - let _ = event_tx.send(MediaEvent::Video(VideoFrame { width, height, y_plane, u_plane, v_plane })); + let candidates = build_decoder_candidates(&ffmpeg, demuxer); + let (child, stdin) = spawn_decoder_process( + &ffmpeg, + &candidates[0], + demuxer, + width, + height, + event_tx.clone(), + log_tx.clone(), + )?; + Ok(Self { + ffmpeg, + demuxer: demuxer.to_string(), + width, + height, + event_tx, + log_tx, + candidates, + current_index: 0, + stdin, + child, + }) + } + + fn write(&mut self, payload: &[u8]) -> anyhow::Result<()> { + loop { + self.ensure_backend()?; + match self.stdin.write_all(payload) { + Ok(()) => return Ok(()), + Err(error) => { + self.log_blocking("warn", format!("decoder backend {} write failed: {error}", self.candidates[self.current_index].name)); + if !self.advance_backend(None)? { + return Err(error).context("write ffmpeg stdin"); + } + } } - }); - thread::spawn(move || { - let mut stderr_buf = String::new(); - let _ = stderr.read_to_string(&mut stderr_buf); - if !stderr_buf.trim().is_empty() { - let _ = log_tx.blocking_send(StreamerMessage::Log { level: "stderr".into(), message: stderr_buf }); + } + } + + fn ensure_backend(&mut self) -> anyhow::Result<()> { + if let Some(status) = self.child.try_wait().context("try_wait ffmpeg decoder")? { + self.log_blocking( + "warn", + format!( + "decoder backend {} exited early with {}", + self.candidates[self.current_index].name, + format_exit_status(status) + ), + ); + if !self.advance_backend(Some(status))? { + return Err(anyhow!("all decoder backends exhausted")); + } + } + Ok(()) + } + + fn advance_backend(&mut self, _status: Option) -> anyhow::Result { + while self.current_index + 1 < self.candidates.len() { + self.current_index += 1; + let candidate = self.candidates[self.current_index].clone(); + match spawn_decoder_process( + &self.ffmpeg, + &candidate, + &self.demuxer, + self.width, + self.height, + self.event_tx.clone(), + self.log_tx.clone(), + ) { + Ok((child, stdin)) => { + self.child = child; + self.stdin = stdin; + return Ok(true); + } + Err(error) => { + self.log_blocking("warn", format!("decoder backend {} failed to start: {error:#}", candidate.name)); + } } + } + Ok(false) + } + + fn log_blocking(&self, level: &str, message: String) { + let _ = self.log_tx.blocking_send(StreamerMessage::Log { + level: level.to_string(), + message, }); - let stdin = child.stdin.take().ok_or_else(|| anyhow!("missing ffmpeg stdin"))?; - Ok(Self { stdin, _child: child }) } +} - fn write(&mut self, payload: &[u8]) -> anyhow::Result<()> { - self.stdin.write_all(payload).context("write ffmpeg stdin")?; - Ok(()) +#[derive(Clone)] +struct DecoderCandidate { + name: String, + args: Vec, +} + +fn spawn_decoder_process( + ffmpeg: &Path, + candidate: &DecoderCandidate, + demuxer: &str, + width: u32, + height: u32, + event_tx: Sender, + log_tx: tokio::sync::mpsc::Sender, +) -> anyhow::Result<(Child, ChildStdin)> { + let mut command = Command::new(ffmpeg); + command + .args(&candidate.args) + .arg("-f") + .arg(demuxer) + .arg("-i") + .arg("pipe:0") + .arg("-an") + .arg("-sn") + .arg("-dn") + .arg("-pix_fmt") + .arg("yuv420p") + .arg("-f") + .arg("rawvideo") + .arg("pipe:1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = command + .spawn() + .with_context(|| format!("spawn ffmpeg decoder backend {}", candidate.name))?; + + let y_size = (width * height) as usize; + let uv_size = ((width / 2) * (height / 2)) as usize; + let frame_size = y_size + uv_size + uv_size; + let mut stdout = child.stdout.take().ok_or_else(|| anyhow!("missing ffmpeg stdout"))?; + let mut stderr = child.stderr.take().ok_or_else(|| anyhow!("missing ffmpeg stderr"))?; + let backend_name = candidate.name.clone(); + let _ = log_tx.blocking_send(StreamerMessage::Log { + level: "info".into(), + message: format!("starting decoder backend {}", backend_name), + }); + thread::spawn(move || { + let mut buffer = vec![0_u8; frame_size]; + while stdout.read_exact(&mut buffer).is_ok() { + let y_plane = buffer[..y_size].to_vec(); + let u_plane = buffer[y_size..(y_size + uv_size)].to_vec(); + let v_plane = buffer[(y_size + uv_size)..].to_vec(); + let _ = event_tx.send(MediaEvent::Video(VideoFrame { width, height, y_plane, u_plane, v_plane })); + } + }); + thread::spawn(move || { + let mut stderr_buf = String::new(); + let _ = stderr.read_to_string(&mut stderr_buf); + if !stderr_buf.trim().is_empty() { + let _ = log_tx.blocking_send(StreamerMessage::Log { + level: "stderr".into(), + message: format!("[{}] {}", backend_name, stderr_buf.trim()), + }); + } + }); + let stdin = child.stdin.take().ok_or_else(|| anyhow!("missing ffmpeg stdin"))?; + Ok((child, stdin)) +} + +fn build_decoder_candidates(ffmpeg: &Path, demuxer: &str) -> Vec { + let hwaccels = query_hwaccels(ffmpeg); + let mut candidates = Vec::new(); + + #[cfg(target_os = "windows")] + { + if hwaccels.contains("d3d11va") || hwaccels.contains("qsv") || hwaccels.is_empty() { + candidates.push(DecoderCandidate { + name: "windows-d3d11va-copyback".into(), + args: base_ffmpeg_args(&[ + "-hwaccel", "d3d11va", + "-hwaccel_output_format", "d3d11", + "-vf", "hwdownload,format=yuv420p", + ]), + }); + } + if hwaccels.contains("qsv") { + candidates.push(DecoderCandidate { + name: "windows-qsv-copyback".into(), + args: base_ffmpeg_args(&[ + "-hwaccel", "qsv", + "-hwaccel_output_format", "qsv", + "-vf", "hwdownload,format=yuv420p", + ]), + }); + } + } + + #[cfg(target_os = "macos")] + { + candidates.push(DecoderCandidate { + name: "macos-videotoolbox-copyback".into(), + args: base_ffmpeg_args(&[ + "-hwaccel", "videotoolbox", + "-vf", "format=yuv420p", + ]), + }); + } + + #[cfg(target_os = "linux")] + { + if hwaccels.contains("vulkan") { + candidates.push(DecoderCandidate { + name: "linux-vulkan-copyback".into(), + args: base_ffmpeg_args(&[ + "-init_hw_device", "vulkan=vk:0", + "-filter_hw_device", "vk", + "-hwaccel", "vulkan", + "-hwaccel_output_format", "vulkan", + "-extra_hw_frames", "8", + "-vf", "hwdownload,format=yuv420p", + ]), + }); + } + if hwaccels.contains("vaapi") { + if let Some(render_node) = linux_render_node() { + candidates.push(DecoderCandidate { + name: "linux-vaapi-copyback".into(), + args: base_ffmpeg_args(&[ + "-vaapi_device", render_node.as_str(), + "-hwaccel", "vaapi", + "-hwaccel_output_format", "vaapi", + "-extra_hw_frames", "8", + "-vf", "hwdownload,format=yuv420p", + ]), + }); + } + } + if hwaccels.contains("cuda") { + candidates.push(DecoderCandidate { + name: "linux-cuda-copyback".into(), + args: base_ffmpeg_args(&[ + "-hwaccel", "cuda", + "-hwaccel_output_format", "cuda", + "-extra_hw_frames", "8", + "-vf", "hwdownload,format=yuv420p", + ]), + }); + } + if hwaccels.contains("drm") { + candidates.push(DecoderCandidate { + name: "linux-drm-copyback".into(), + args: base_ffmpeg_args(&[ + "-hwaccel", "drm", + "-vf", "format=yuv420p", + ]), + }); + } + } + + candidates.push(DecoderCandidate { + name: if demuxer == "av1" { + "software-dav1d".into() + } else { + format!("software-{demuxer}") + }, + args: if demuxer == "av1" { + base_ffmpeg_args(&["-c:v", "libdav1d"]) + } else { + base_ffmpeg_args(&[]) + }, + }); + + if let Ok(force_backend) = env::var("OPENNOW_STREAMER_DECODER_BACKEND") { + let force_backend = force_backend.to_lowercase(); + candidates.sort_by_key(|candidate| if candidate.name.to_lowercase().contains(&force_backend) { 0 } else { 1 }); + } + + candidates +} + +fn base_ffmpeg_args(extra: &[&str]) -> Vec { + let mut args = vec![ + "-loglevel".into(), + "warning".into(), + "-fflags".into(), + "nobuffer".into(), + "-flags".into(), + "low_delay".into(), + "-flags2".into(), + "fast".into(), + "-probesize".into(), + "32".into(), + "-analyzeduration".into(), + "0".into(), + "-thread_queue_size".into(), + "4".into(), + "-threads".into(), + "1".into(), + ]; + args.extend(extra.iter().map(|value| value.to_string())); + args +} + +fn query_hwaccels(ffmpeg: &Path) -> HashSet { + Command::new(ffmpeg) + .arg("-hide_banner") + .arg("-hwaccels") + .output() + .ok() + .map(|output| { + String::from_utf8_lossy(&output.stdout) + .lines() + .skip(1) + .map(|line| line.trim().to_lowercase()) + .filter(|line| !line.is_empty()) + .collect::>() + }) + .unwrap_or_default() +} + +#[cfg(target_os = "linux")] +fn linux_render_node() -> Option { + ["/dev/dri/renderD128", "/dev/dri/renderD129"] + .into_iter() + .find(|path| Path::new(path).exists()) + .map(ToString::to_string) +} + +#[cfg(not(target_os = "linux"))] +fn linux_render_node() -> Option { + None +} + +fn format_exit_status(status: ExitStatus) -> String { + match status.code() { + Some(code) => format!("exit code {code}"), + None => "terminated by signal".into(), } } From 78f984ae9ac4ae1fb54bae0c87142a9330329318 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:43:10 +0000 Subject: [PATCH 22/30] Fix macOS SDL bundled build config Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- opennow-streamer/.cargo/config.toml | 2 ++ opennow-streamer/README.md | 3 +++ 2 files changed, 5 insertions(+) create mode 100644 opennow-streamer/.cargo/config.toml diff --git a/opennow-streamer/.cargo/config.toml b/opennow-streamer/.cargo/config.toml new file mode 100644 index 00000000..11d8c84f --- /dev/null +++ b/opennow-streamer/.cargo/config.toml @@ -0,0 +1,2 @@ +[env] +CMAKE_POLICY_VERSION_MINIMUM = "3.5" diff --git a/opennow-streamer/README.md b/opennow-streamer/README.md index 394580c1..3cafdb80 100644 --- a/opennow-streamer/README.md +++ b/opennow-streamer/README.md @@ -53,3 +53,6 @@ Packaging/runtime model: - packaged OpenNOW builds copy `opennow-streamer` and a colocated `ffmpeg` sidecar into `resources/bin/` via `opennow-stable/scripts/bundle-native-runtime.mjs` - `opennow-stable` build now runs `cargo build --release` for the native streamer and bundles both binaries into Electron extra resources - at runtime the streamer resolves `ffmpeg` relative to its own executable first, then `resources/bin`, then `OPENNOW_FFMPEG_BIN` for development overrides + +Build note: +- `opennow-streamer/.cargo/config.toml` sets `CMAKE_POLICY_VERSION_MINIMUM=3.5` so the bundled SDL build still configures on newer CMake releases used on current macOS developer machines From 382a20f2560e6526b0ed3c6501d0f568ca8037c9 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:48:52 +0000 Subject: [PATCH 23/30] Add macOS native streamer repo paths Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- opennow-stable/src/main/services/streamerManager.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/opennow-stable/src/main/services/streamerManager.ts b/opennow-stable/src/main/services/streamerManager.ts index c2a4e11a..0785c00f 100644 --- a/opennow-stable/src/main/services/streamerManager.ts +++ b/opennow-stable/src/main/services/streamerManager.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow } from "electron"; import { createServer, type Server, type Socket } from "node:net"; +import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { existsSync } from "node:fs"; import { type ChildProcess, spawn } from "node:child_process"; @@ -188,11 +189,14 @@ export class StreamerManager { const mainDir = dirname(__filename); const suffix = process.platform === "win32" ? ".exe" : ""; const envOverride = process.env.OPENNOW_STREAMER_BIN; + const home = homedir(); const repoCandidates = [ process.cwd(), app.getAppPath(), resolve(app.getAppPath(), ".."), "/home/zortos/Projects/OpenNOW", + "/Users/zortos/Projects/OpenNOW", + resolve(home, "Projects/OpenNOW"), ].flatMap((root) => [ resolve(root, `opennow-streamer/target/release/opennow-streamer${suffix}`), resolve(root, `opennow-streamer/target/debug/opennow-streamer${suffix}`), From 86dd1c8e5982565db4cd972780898be897660f75 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:12:11 +0000 Subject: [PATCH 24/30] Run macOS streamer window on main thread Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- opennow-streamer/src/main.rs | 67 +++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/opennow-streamer/src/main.rs b/opennow-streamer/src/main.rs index 29186c18..1a952cac 100644 --- a/opennow-streamer/src/main.rs +++ b/opennow-streamer/src/main.rs @@ -6,46 +6,87 @@ mod sdp; mod session; mod window; -use std::sync::{Arc, mpsc}; +use std::{sync::{Arc, mpsc}, thread}; +use anyhow::Context; use tokio::sync::mpsc as tokio_mpsc; use clap::Parser; -use tokio::sync::Mutex; +use tokio::{runtime::Builder, sync::Mutex}; use crate::{ control::{connect, send}, media::MediaEvent, messages::{StreamerMessage, StreamerState}, - session::{handle_control_message, SharedSession}, + session::{handle_control_message, InputPayload, SharedSession}, }; -#[derive(Parser, Debug)] +#[derive(Parser, Debug, Clone)] struct Args { #[arg(long)] control_url: String, } -#[tokio::main(flavor = "multi_thread")] -async fn main() -> anyhow::Result<()> { +fn main() -> anyhow::Result<()> { env_logger::init(); let args = Args::parse(); - let (control_tx, mut control_rx) = connect(&args.control_url).await?; - send(&control_tx, StreamerMessage::Hello { version: 1, pid: std::process::id() }).await; - send(&control_tx, StreamerMessage::State { state: StreamerState::Idle, detail: Some("booted".into()) }).await; - let active: SharedSession = Arc::new(Mutex::new(None)); - let window_session = active.clone(); let (media_tx, media_rx) = mpsc::channel::(); - let (input_tx, mut input_rx) = tokio_mpsc::unbounded_channel(); - std::thread::spawn(move || { + let (input_tx, input_rx) = tokio_mpsc::unbounded_channel(); + let (shutdown_tx, shutdown_rx) = tokio_mpsc::unbounded_channel::<()>(); + + if cfg!(target_os = "macos") { + let runtime_active = active.clone(); + let runtime_media_tx = media_tx.clone(); + let runtime_thread = thread::spawn(move || { + let runtime = Builder::new_multi_thread() + .enable_all() + .build() + .context("build tokio runtime") + .and_then(|rt| rt.block_on(run_runtime(args, runtime_active, runtime_media_tx, input_rx, shutdown_rx))); + if let Err(error) = runtime { + eprintln!("runtime loop failed: {error:#}"); + } + }); + + let result = window::run(active, media_rx, input_tx, 1920, 1080); + let _ = shutdown_tx.send(()); + let _ = runtime_thread.join(); + return result; + } + + let window_session = active.clone(); + thread::spawn(move || { if let Err(error) = window::run(window_session, media_rx, input_tx, 1920, 1080) { eprintln!("window loop failed: {error:#}"); } }); + let runtime = Builder::new_multi_thread().enable_all().build().context("build tokio runtime")?; + runtime.block_on(run_runtime(args, active, media_tx, input_rx, shutdown_rx)) +} + +async fn run_runtime( + args: Args, + active: SharedSession, + media_tx: mpsc::Sender, + mut input_rx: tokio_mpsc::UnboundedReceiver, + mut shutdown_rx: tokio_mpsc::UnboundedReceiver<()>, +) -> anyhow::Result<()> { + let (control_tx, mut control_rx) = connect(&args.control_url).await?; + send(&control_tx, StreamerMessage::Hello { version: 1, pid: std::process::id() }).await; + send(&control_tx, StreamerMessage::State { state: StreamerState::Idle, detail: Some("booted".into()) }).await; + loop { tokio::select! { + maybe_shutdown = shutdown_rx.recv() => { + if maybe_shutdown.is_some() { + if let Some(session) = active.lock().await.take() { + session.close().await; + } + break; + } + } maybe_payload = input_rx.recv() => { match maybe_payload { Some(payload) => { From e4a4e287a2451bcb6cf32a2a757e92cbb2efdaba Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:40:45 +0000 Subject: [PATCH 25/30] fix(streamer): improve macos dev launcher --- .../src/main/services/streamerManager.ts | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/opennow-stable/src/main/services/streamerManager.ts b/opennow-stable/src/main/services/streamerManager.ts index 0785c00f..aa10efee 100644 --- a/opennow-stable/src/main/services/streamerManager.ts +++ b/opennow-stable/src/main/services/streamerManager.ts @@ -96,6 +96,13 @@ export class StreamerManager { .filter((value): value is string => Boolean(value && value.length > 0)) .join(":"); } + if (process.platform === "darwin") { + const binaryDir = dirname(binaryPath); + runtimeEnv.DYLD_LIBRARY_PATH = [binaryDir, runtimeEnv.DYLD_LIBRARY_PATH] + .filter((value): value is string => Boolean(value && value.length > 0)) + .join(":"); + } + runtimeEnv.RUST_BACKTRACE ??= "1"; const child = spawn(binaryPath, ["--control-url", `tcp://127.0.0.1:${port}`], { stdio: ["ignore", "pipe", "pipe"], @@ -190,17 +197,25 @@ export class StreamerManager { const suffix = process.platform === "win32" ? ".exe" : ""; const envOverride = process.env.OPENNOW_STREAMER_BIN; const home = homedir(); - const repoCandidates = [ + const repoRoots = [ process.cwd(), app.getAppPath(), resolve(app.getAppPath(), ".."), "/home/zortos/Projects/OpenNOW", "/Users/zortos/Projects/OpenNOW", resolve(home, "Projects/OpenNOW"), - ].flatMap((root) => [ - resolve(root, `opennow-streamer/target/release/opennow-streamer${suffix}`), - resolve(root, `opennow-streamer/target/debug/opennow-streamer${suffix}`), - ]); + ]; + const repoCandidates = repoRoots.flatMap((root) => + app.isPackaged + ? [ + resolve(root, `opennow-streamer/target/release/opennow-streamer${suffix}`), + resolve(root, `opennow-streamer/target/debug/opennow-streamer${suffix}`), + ] + : [ + resolve(root, `opennow-streamer/target/debug/opennow-streamer${suffix}`), + resolve(root, `opennow-streamer/target/release/opennow-streamer${suffix}`), + ], + ); const candidates = [ ...(envOverride ? [envOverride] : []), resolve(mainDir, `../../../../opennow-streamer/target/release/opennow-streamer${suffix}`), From bef9b84611973f0a9227020c4a6179fe2385892c Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:06:58 +0000 Subject: [PATCH 26/30] fix(streamer): avoid decoder log runtime panic --- opennow-streamer/src/media.rs | 288 +++++++++++++++++++++++++--------- 1 file changed, 217 insertions(+), 71 deletions(-) diff --git a/opennow-streamer/src/media.rs b/opennow-streamer/src/media.rs index a2e88eb0..907a030f 100644 --- a/opennow-streamer/src/media.rs +++ b/opennow-streamer/src/media.rs @@ -12,7 +12,10 @@ use anyhow::{anyhow, Context}; use bytes::Bytes; use opus::{Channels, Decoder as OpusDecoder}; use rtp::{ - codecs::{h264::H264Packet, h265::{H265Packet, H265Payload}}, + codecs::{ + h264::H264Packet, + h265::{H265Packet, H265Payload}, + }, packetizer::Depacketizer, }; use webrtc::rtp_transceiver::rtp_codec::RTCRtpCodecCapability; @@ -56,32 +59,62 @@ pub struct VideoSettings { } impl MediaPipeline { - pub fn new(event_tx: Sender, log_tx: tokio::sync::mpsc::Sender, video_settings: VideoSettings) -> Self { - Self { event_tx, log_tx, video_settings } + pub fn new( + event_tx: Sender, + log_tx: tokio::sync::mpsc::Sender, + video_settings: VideoSettings, + ) -> Self { + Self { + event_tx, + log_tx, + video_settings, + } } - pub async fn attach_video_track(&self, track: Arc) -> anyhow::Result<()> { + pub async fn attach_video_track( + &self, + track: Arc, + ) -> anyhow::Result<()> { let codec = track.codec().capability; let mime = codec.mime_type.to_lowercase(); let event_tx = self.event_tx.clone(); let log_tx = self.log_tx.clone(); let settings = self.video_settings.clone(); tokio::spawn(async move { - if let Err(error) = run_video_track(track, codec, settings, event_tx, log_tx.clone()).await { - let _ = log_tx.send(StreamerMessage::Error { message: format!("video pipeline failed: {error:#}") }).await; + if let Err(error) = + run_video_track(track, codec, settings, event_tx, log_tx.clone()).await + { + let _ = log_tx + .send(StreamerMessage::Error { + message: format!("video pipeline failed: {error:#}"), + }) + .await; } }); - let _ = self.log_tx.send(StreamerMessage::Log { level: "info".into(), message: format!("attached video track {mime}") }).await; + let _ = self + .log_tx + .send(StreamerMessage::Log { + level: "info".into(), + message: format!("attached video track {mime}"), + }) + .await; Ok(()) } - pub async fn attach_audio_track(&self, track: Arc) -> anyhow::Result<()> { + pub async fn attach_audio_track( + &self, + track: Arc, + ) -> anyhow::Result<()> { let codec = track.codec().capability; let event_tx = self.event_tx.clone(); let log_tx = self.log_tx.clone(); tokio::spawn(async move { if let Err(error) = run_audio_track(track, codec, event_tx, log_tx.clone()).await { - let _ = log_tx.send(StreamerMessage::Error { message: format!("audio pipeline failed: {error:#}") }).await; + let _ = log_tx + .send(StreamerMessage::Error { + message: format!("audio pipeline failed: {error:#}"), + }) + .await; } }); Ok(()) @@ -101,10 +134,19 @@ async fn run_video_track( } else if codec_name.contains("h264") { "h264" } else { - return Err(anyhow!("unsupported video codec for MVP decode path: {}", codec.mime_type)); + return Err(anyhow!( + "unsupported video codec for MVP decode path: {}", + codec.mime_type + )); }; - let mut decoder = FfmpegVideoDecoder::spawn(ffmpeg_demuxer, settings.width, settings.height, event_tx, log_tx.clone())?; + let mut decoder = FfmpegVideoDecoder::spawn( + ffmpeg_demuxer, + settings.width, + settings.height, + event_tx, + log_tx.clone(), + )?; let mut h264 = H264Packet::default(); let mut h265 = H265Assembler::default(); loop { @@ -114,7 +156,12 @@ async fn run_video_track( Ok(bytes) if !bytes.is_empty() => bytes.as_ref().to_vec(), Ok(_) => Vec::new(), Err(error) => { - let _ = log_tx.send(StreamerMessage::Log { level: "warn".into(), message: format!("h264 depacketize: {error}") }).await; + let _ = log_tx + .send(StreamerMessage::Log { + level: "warn".into(), + message: format!("h264 depacketize: {error}"), + }) + .await; Vec::new() } } @@ -122,7 +169,12 @@ async fn run_video_track( match h265.push(packet.payload.clone()) { Ok(bytes) => bytes, Err(error) => { - let _ = log_tx.send(StreamerMessage::Log { level: "warn".into(), message: format!("h265 depacketize: {error}") }).await; + let _ = log_tx + .send(StreamerMessage::Log { + level: "warn".into(), + message: format!("h265 depacketize: {error}"), + }) + .await; Vec::new() } } @@ -140,21 +192,43 @@ async fn run_audio_track( _log_tx: tokio::sync::mpsc::Sender, ) -> anyhow::Result<()> { if !codec.mime_type.to_lowercase().contains("opus") { - return Err(anyhow!("unsupported audio codec for MVP decode path: {}", codec.mime_type)); + return Err(anyhow!( + "unsupported audio codec for MVP decode path: {}", + codec.mime_type + )); } let sample_rate = codec.clock_rate.max(48_000); - let channels = if codec.channels == 0 { 2 } else { codec.channels as usize }; + let channels = if codec.channels == 0 { + 2 + } else { + codec.channels as usize + }; let mut depacketizer = rtp::codecs::opus::OpusPacket::default(); - let mut decoder = OpusDecoder::new(sample_rate, if channels > 1 { Channels::Stereo } else { Channels::Mono })?; + let mut decoder = OpusDecoder::new( + sample_rate, + if channels > 1 { + Channels::Stereo + } else { + Channels::Mono + }, + )?; let mut pcm = vec![0_i16; 960 * channels * 6]; loop { let (packet, _) = track.read_rtp().await.context("read_rtp audio")?; - let opus = depacketizer.depacketize(&packet.payload).context("depacketize opus")?; - let frame_samples = decoder.decode(&opus, &mut pcm, false).context("decode opus")?; + let opus = depacketizer + .depacketize(&packet.payload) + .context("depacketize opus")?; + let frame_samples = decoder + .decode(&opus, &mut pcm, false) + .context("decode opus")?; if frame_samples > 0 { let used = frame_samples * channels; let samples = pcm[..used].to_vec(); - let _ = event_tx.send(MediaEvent::Audio(AudioFrame { samples, channels: channels as u8, sample_rate })); + let _ = event_tx.send(MediaEvent::Audio(AudioFrame { + samples, + channels: channels as u8, + sample_rate, + })); } } } @@ -211,7 +285,13 @@ impl FfmpegVideoDecoder { match self.stdin.write_all(payload) { Ok(()) => return Ok(()), Err(error) => { - self.log_blocking("warn", format!("decoder backend {} write failed: {error}", self.candidates[self.current_index].name)); + self.log_blocking( + "warn", + format!( + "decoder backend {} write failed: {error}", + self.candidates[self.current_index].name + ), + ); if !self.advance_backend(None)? { return Err(error).context("write ffmpeg stdin"); } @@ -256,7 +336,13 @@ impl FfmpegVideoDecoder { return Ok(true); } Err(error) => { - self.log_blocking("warn", format!("decoder backend {} failed to start: {error:#}", candidate.name)); + self.log_blocking( + "warn", + format!( + "decoder backend {} failed to start: {error:#}", + candidate.name + ), + ); } } } @@ -264,10 +350,7 @@ impl FfmpegVideoDecoder { } fn log_blocking(&self, level: &str, message: String) { - let _ = self.log_tx.blocking_send(StreamerMessage::Log { - level: level.to_string(), - message, - }); + send_streamer_log(&self.log_tx, level, message); } } @@ -312,36 +395,64 @@ fn spawn_decoder_process( let y_size = (width * height) as usize; let uv_size = ((width / 2) * (height / 2)) as usize; let frame_size = y_size + uv_size + uv_size; - let mut stdout = child.stdout.take().ok_or_else(|| anyhow!("missing ffmpeg stdout"))?; - let mut stderr = child.stderr.take().ok_or_else(|| anyhow!("missing ffmpeg stderr"))?; + let mut stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("missing ffmpeg stdout"))?; + let mut stderr = child + .stderr + .take() + .ok_or_else(|| anyhow!("missing ffmpeg stderr"))?; let backend_name = candidate.name.clone(); - let _ = log_tx.blocking_send(StreamerMessage::Log { - level: "info".into(), - message: format!("starting decoder backend {}", backend_name), - }); + send_streamer_log( + &log_tx, + "info", + format!("starting decoder backend {}", backend_name), + ); thread::spawn(move || { let mut buffer = vec![0_u8; frame_size]; while stdout.read_exact(&mut buffer).is_ok() { let y_plane = buffer[..y_size].to_vec(); let u_plane = buffer[y_size..(y_size + uv_size)].to_vec(); let v_plane = buffer[(y_size + uv_size)..].to_vec(); - let _ = event_tx.send(MediaEvent::Video(VideoFrame { width, height, y_plane, u_plane, v_plane })); + let _ = event_tx.send(MediaEvent::Video(VideoFrame { + width, + height, + y_plane, + u_plane, + v_plane, + })); } }); thread::spawn(move || { let mut stderr_buf = String::new(); let _ = stderr.read_to_string(&mut stderr_buf); if !stderr_buf.trim().is_empty() { - let _ = log_tx.blocking_send(StreamerMessage::Log { - level: "stderr".into(), - message: format!("[{}] {}", backend_name, stderr_buf.trim()), - }); + send_streamer_log( + &log_tx, + "stderr", + format!("[{}] {}", backend_name, stderr_buf.trim()), + ); } }); - let stdin = child.stdin.take().ok_or_else(|| anyhow!("missing ffmpeg stdin"))?; + let stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("missing ffmpeg stdin"))?; Ok((child, stdin)) } +fn send_streamer_log( + log_tx: &tokio::sync::mpsc::Sender, + level: &str, + message: String, +) { + let _ = log_tx.try_send(StreamerMessage::Log { + level: level.to_string(), + message, + }); +} + fn build_decoder_candidates(ffmpeg: &Path, demuxer: &str) -> Vec { let hwaccels = query_hwaccels(ffmpeg); let mut candidates = Vec::new(); @@ -352,9 +463,12 @@ fn build_decoder_candidates(ffmpeg: &Path, demuxer: &str) -> Vec Vec Vec Vec Vec Vec Vec anyhow::Result { } } let exe = env::current_exe().context("current_exe")?; - let suffix = if cfg!(target_os = "windows") { ".exe" } else { "" }; + let suffix = if cfg!(target_os = "windows") { + ".exe" + } else { + "" + }; let candidates = [ exe.parent().map(|p| p.join(format!("ffmpeg{suffix}"))), - exe.parent().and_then(Path::parent).map(|p| p.join("bin").join(format!("ffmpeg{suffix}"))), + exe.parent() + .and_then(Path::parent) + .map(|p| p.join("bin").join(format!("ffmpeg{suffix}"))), Some(PathBuf::from(format!("ffmpeg{suffix}"))), ]; - candidates.into_iter().flatten().find(|candidate| candidate.exists() || candidate == &PathBuf::from(format!("ffmpeg{suffix}"))).ok_or_else(|| anyhow!("unable to locate bundled ffmpeg runtime")) + candidates + .into_iter() + .flatten() + .find(|candidate| { + candidate.exists() || candidate == &PathBuf::from(format!("ffmpeg{suffix}")) + }) + .ok_or_else(|| anyhow!("unable to locate bundled ffmpeg runtime")) } From 8ba861d9122b199c445e6d6b2c810c57c581ef72 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:14:52 +0000 Subject: [PATCH 27/30] fix(streamer): handle dynamic frame sizes on macos --- opennow-streamer/src/media.rs | 54 +++-- opennow-streamer/src/window.rs | 407 ++++++++++++++++++++++++--------- 2 files changed, 334 insertions(+), 127 deletions(-) diff --git a/opennow-streamer/src/media.rs b/opennow-streamer/src/media.rs index 907a030f..4adb4c70 100644 --- a/opennow-streamer/src/media.rs +++ b/opennow-streamer/src/media.rs @@ -357,7 +357,8 @@ impl FfmpegVideoDecoder { #[derive(Clone)] struct DecoderCandidate { name: String, - args: Vec, + input_args: Vec, + output_args: Vec, } fn spawn_decoder_process( @@ -371,11 +372,12 @@ fn spawn_decoder_process( ) -> anyhow::Result<(Child, ChildStdin)> { let mut command = Command::new(ffmpeg); command - .args(&candidate.args) + .args(&candidate.input_args) .arg("-f") .arg(demuxer) .arg("-i") .arg("pipe:0") + .args(&candidate.output_args) .arg("-an") .arg("-sn") .arg("-dn") @@ -462,27 +464,25 @@ fn build_decoder_candidates(ffmpeg: &Path, demuxer: &str) -> Vec Vec Vec Vec Vec Vec Vec Vec { +fn base_ffmpeg_input_args(extra: &[&str]) -> Vec { let mut args = vec![ "-loglevel".into(), "warning".into(), @@ -601,13 +601,17 @@ fn base_ffmpeg_args(extra: &[&str]) -> Vec { "0".into(), "-thread_queue_size".into(), "4".into(), - "-threads".into(), - "1".into(), ]; args.extend(extra.iter().map(|value| value.to_string())); args } +fn ffmpeg_output_args(extra: &[&str]) -> Vec { + let mut args = vec!["-threads".into(), "1".into()]; + args.extend(extra.iter().map(|value| value.to_string())); + args +} + fn query_hwaccels(ffmpeg: &Path) -> HashSet { Command::new(ffmpeg) .arg("-hide_banner") diff --git a/opennow-streamer/src/window.rs b/opennow-streamer/src/window.rs index 5ea5f41e..59b5d732 100644 --- a/opennow-streamer/src/window.rs +++ b/opennow-streamer/src/window.rs @@ -1,4 +1,8 @@ -use std::{collections::{HashMap, HashSet}, sync::mpsc::Receiver, time::Duration}; +use std::{ + collections::{HashMap, HashSet}, + sync::mpsc::Receiver, + time::Duration, +}; use tokio::sync::mpsc::UnboundedSender; @@ -22,7 +26,13 @@ use crate::{ session::{InputPayload, SharedSession}, }; -pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: UnboundedSender, width: u32, height: u32) -> anyhow::Result<()> { +pub fn run( + _session: SharedSession, + media_rx: Receiver, + input_tx: UnboundedSender, + width: u32, + height: u32, +) -> anyhow::Result<()> { hint::set("SDL_MOUSE_AUTO_CAPTURE", "0"); hint::set("SDL_MOUSE_FOCUS_CLICKTHROUGH", "1"); hint::set("SDL_MOUSE_RELATIVE_MODE_WARP", "0"); @@ -32,9 +42,17 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un hint::set("SDL_MOUSE_TOUCH_EVENTS", "0"); hint::set_video_minimize_on_focus_loss(false); - let sdl = sdl2::init().map_err(|e| anyhow::anyhow!(e)).context("sdl init")?; - let video = sdl.video().map_err(|e| anyhow::anyhow!(e)).context("sdl video")?; - let audio = sdl.audio().map_err(|e| anyhow::anyhow!(e)).context("sdl audio")?; + let sdl = sdl2::init() + .map_err(|e| anyhow::anyhow!(e)) + .context("sdl init")?; + let video = sdl + .video() + .map_err(|e| anyhow::anyhow!(e)) + .context("sdl video")?; + let audio = sdl + .audio() + .map_err(|e| anyhow::anyhow!(e)) + .context("sdl audio")?; let mouse = sdl.mouse(); let game_controller = sdl.game_controller().ok(); let mut opened_controllers: HashMap = HashMap::new(); @@ -54,20 +72,36 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un .resizable() .build() .context("create SDL window")?; - let mut canvas = window.into_canvas().accelerated().build().context("create SDL canvas")?; + let mut canvas = window + .into_canvas() + .accelerated() + .build() + .context("create SDL canvas")?; let texture_creator: TextureCreator = canvas.texture_creator(); let mut texture = texture_creator .create_texture_streaming(PixelFormatEnum::IYUV, width, height) .context("create texture")?; texture.set_blend_mode(BlendMode::None); + let mut texture_width = width; + let mut texture_height = height; let queue: AudioQueue = audio - .open_queue::(None, &AudioSpecDesired { freq: Some(48_000), channels: Some(2), samples: Some(1024) }) + .open_queue::( + None, + &AudioSpecDesired { + freq: Some(48_000), + channels: Some(2), + samples: Some(1024), + }, + ) .map_err(|e| anyhow::anyhow!(e)) .context("open SDL audio queue")?; queue.resume(); - let mut event_pump = sdl.event_pump().map_err(|e| anyhow::anyhow!(e)).context("event pump")?; + let mut event_pump = sdl + .event_pump() + .map_err(|e| anyhow::anyhow!(e)) + .context("event pump")?; let mut latest_frame: Option = None; let mut connected_slots = HashSet::::new(); let mut mouse_captured = false; @@ -81,6 +115,16 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un } if let Some(frame) = latest_frame.take() { + if frame.width != texture_width || frame.height != texture_height { + texture = texture_creator + .create_texture_streaming(PixelFormatEnum::IYUV, frame.width, frame.height) + .context("recreate texture")?; + texture.set_blend_mode(BlendMode::None); + texture_width = frame.width; + texture_height = frame.height; + let window = canvas.window_mut(); + let _ = window.set_size(frame.width.max(640), frame.height.max(360)); + } texture .update_yuv( None, @@ -96,14 +140,18 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un canvas.clear(); let (out_w, out_h) = canvas.output_size().unwrap_or((width, height)); - canvas.copy(&texture, None, Some(Rect::new(0, 0, out_w, out_h))).ok(); + canvas + .copy(&texture, None, Some(Rect::new(0, 0, out_w, out_h))) + .ok(); canvas.present(); for event in event_pump.poll_iter() { match event { Event::Quit { .. } => running = false, Event::Window { win_event, .. } => match win_event { - sdl2::event::WindowEvent::FocusLost | sdl2::event::WindowEvent::Leave | sdl2::event::WindowEvent::Close => { + sdl2::event::WindowEvent::FocusLost + | sdl2::event::WindowEvent::Leave + | sdl2::event::WindowEvent::Close => { if mouse_captured { set_mouse_capture(&mouse, &mut canvas, false); mouse_captured = false; @@ -111,46 +159,75 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un } _ => {} }, - Event::KeyDown { scancode: Some(Scancode::Escape), repeat: false, .. } => { + Event::KeyDown { + scancode: Some(Scancode::Escape), + repeat: false, + .. + } => { if mouse_captured { set_mouse_capture(&mouse, &mut canvas, false); mouse_captured = false; } else if let Some((vk, code)) = map_scancode(Scancode::Escape) { - send_input(&input_tx, InputPayload::Key { - key_code: vk, - scan_code: code, - modifiers: 0, - down: true, - }); + send_input( + &input_tx, + InputPayload::Key { + key_code: vk, + scan_code: code, + modifiers: 0, + down: true, + }, + ); } } - Event::KeyDown { scancode: Some(scancode), keymod, repeat, .. } => { + Event::KeyDown { + scancode: Some(scancode), + keymod, + repeat, + .. + } => { if !repeat { if let Some((vk, code)) = map_scancode(scancode) { - send_input(&input_tx, InputPayload::Key { - key_code: vk, - scan_code: code, - modifiers: map_modifiers(keymod), - down: true, - }); + send_input( + &input_tx, + InputPayload::Key { + key_code: vk, + scan_code: code, + modifiers: map_modifiers(keymod), + down: true, + }, + ); } } } - Event::KeyUp { scancode: Some(scancode), keymod, repeat, .. } => { + Event::KeyUp { + scancode: Some(scancode), + keymod, + repeat, + .. + } => { if !repeat { if let Some((vk, code)) = map_scancode(scancode) { - send_input(&input_tx, InputPayload::Key { - key_code: vk, - scan_code: code, - modifiers: map_modifiers(keymod), - down: false, - }); + send_input( + &input_tx, + InputPayload::Key { + key_code: vk, + scan_code: code, + modifiers: map_modifiers(keymod), + down: false, + }, + ); } } } Event::MouseMotion { xrel, yrel, .. } => { if mouse_captured && (xrel != 0 || yrel != 0) { - send_input(&input_tx, InputPayload::MouseMove { dx: xrel.clamp(i16::MIN as i32, i16::MAX as i32) as i16, dy: yrel.clamp(i16::MIN as i32, i16::MAX as i32) as i16 }); + send_input( + &input_tx, + InputPayload::MouseMove { + dx: xrel.clamp(i16::MIN as i32, i16::MAX as i32) as i16, + dy: yrel.clamp(i16::MIN as i32, i16::MAX as i32) as i16, + }, + ); } } Event::MouseButtonDown { mouse_btn, .. } => { @@ -165,7 +242,13 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un Event::MouseButtonUp { mouse_btn, .. } => { if mouse_captured { if let Some(button) = map_mouse_button(mouse_btn) { - send_input(&input_tx, InputPayload::MouseButton { button, down: false }); + send_input( + &input_tx, + InputPayload::MouseButton { + button, + down: false, + }, + ); } } } @@ -182,7 +265,13 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un opened_controllers.insert(instance_id, controller); let controller_id = slot_for_instance(instance_id, &opened_controllers); connected_slots.insert(controller_id); - send_controller_state(&input_tx, &opened_controllers, controller_id, Some(instance_id), &connected_slots); + send_controller_state( + &input_tx, + &opened_controllers, + controller_id, + Some(instance_id), + &connected_slots, + ); } } } @@ -190,7 +279,13 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un let controller_id = slot_for_instance(which, &opened_controllers); opened_controllers.remove(&which); connected_slots.remove(&controller_id); - send_controller_state(&input_tx, &opened_controllers, controller_id, None, &connected_slots); + send_controller_state( + &input_tx, + &opened_controllers, + controller_id, + None, + &connected_slots, + ); } Event::ControllerAxisMotion { which, .. } | Event::ControllerButtonDown { which, .. } @@ -198,7 +293,13 @@ pub fn run(_session: SharedSession, media_rx: Receiver, input_tx: Un if opened_controllers.contains_key(&which) { let controller_id = slot_for_instance(which, &opened_controllers); connected_slots.insert(controller_id); - send_controller_state(&input_tx, &opened_controllers, controller_id, Some(which), &connected_slots); + send_controller_state( + &input_tx, + &opened_controllers, + controller_id, + Some(which), + &connected_slots, + ); } } _ => {} @@ -231,37 +332,50 @@ fn send_controller_state( instance_id: Option, connected_slots: &HashSet, ) { - let bitmap = connected_slots.iter().fold(0_u16, |bitmap, slot| bitmap | (1_u16 << slot)); + let bitmap = connected_slots + .iter() + .fold(0_u16, |bitmap, slot| bitmap | (1_u16 << slot)); if let Some(instance_id) = instance_id { if let Some(controller) = controllers.get(&instance_id) { - send_input(input_tx, InputPayload::Gamepad { - controller_id, - bitmap, - buttons: map_controller_buttons(controller), - left_trigger: axis_to_u8(controller.axis(sdl2::controller::Axis::TriggerLeft)), - right_trigger: axis_to_u8(controller.axis(sdl2::controller::Axis::TriggerRight)), - left_x: controller.axis(sdl2::controller::Axis::LeftX), - left_y: -controller.axis(sdl2::controller::Axis::LeftY), - right_x: controller.axis(sdl2::controller::Axis::RightX), - right_y: -controller.axis(sdl2::controller::Axis::RightY), - }); + send_input( + input_tx, + InputPayload::Gamepad { + controller_id, + bitmap, + buttons: map_controller_buttons(controller), + left_trigger: axis_to_u8(controller.axis(sdl2::controller::Axis::TriggerLeft)), + right_trigger: axis_to_u8( + controller.axis(sdl2::controller::Axis::TriggerRight), + ), + left_x: controller.axis(sdl2::controller::Axis::LeftX), + left_y: -controller.axis(sdl2::controller::Axis::LeftY), + right_x: controller.axis(sdl2::controller::Axis::RightX), + right_y: -controller.axis(sdl2::controller::Axis::RightY), + }, + ); return; } } - send_input(input_tx, InputPayload::Gamepad { - controller_id, - bitmap, - buttons: 0, - left_trigger: 0, - right_trigger: 0, - left_x: 0, - left_y: 0, - right_x: 0, - right_y: 0, - }); + send_input( + input_tx, + InputPayload::Gamepad { + controller_id, + bitmap, + buttons: 0, + left_trigger: 0, + right_trigger: 0, + left_x: 0, + left_y: 0, + right_x: 0, + right_y: 0, + }, + ); } -fn slot_for_instance(instance_id: u32, controllers: &HashMap) -> u8 { +fn slot_for_instance( + instance_id: u32, + controllers: &HashMap, +) -> u8 { let mut sorted = controllers.keys().copied().collect::>(); sorted.sort_unstable(); sorted @@ -278,12 +392,24 @@ fn queue_audio(queue: &AudioQueue, frame: AudioFrame) { fn map_modifiers(mods: Mod) -> u16 { let mut flags = 0_u16; - if mods.intersects(Mod::LSHIFTMOD | Mod::RSHIFTMOD) { flags |= 0x01; } - if mods.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD) { flags |= 0x02; } - if mods.intersects(Mod::LALTMOD | Mod::RALTMOD) { flags |= 0x04; } - if mods.intersects(Mod::LGUIMOD | Mod::RGUIMOD) { flags |= 0x08; } - if mods.intersects(Mod::CAPSMOD) { flags |= 0x10; } - if mods.intersects(Mod::NUMMOD) { flags |= 0x20; } + if mods.intersects(Mod::LSHIFTMOD | Mod::RSHIFTMOD) { + flags |= 0x01; + } + if mods.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD) { + flags |= 0x02; + } + if mods.intersects(Mod::LALTMOD | Mod::RALTMOD) { + flags |= 0x04; + } + if mods.intersects(Mod::LGUIMOD | Mod::RGUIMOD) { + flags |= 0x08; + } + if mods.intersects(Mod::CAPSMOD) { + flags |= 0x10; + } + if mods.intersects(Mod::NUMMOD) { + flags |= 0x20; + } flags } @@ -300,50 +426,127 @@ fn map_mouse_button(button: sdl2::mouse::MouseButton) -> Option { fn map_controller_buttons(controller: &sdl2::controller::GameController) -> u16 { let mut buttons = 0_u16; - if controller.button(ControllerButton::DPadUp) { buttons |= 0x0001; } - if controller.button(ControllerButton::DPadDown) { buttons |= 0x0002; } - if controller.button(ControllerButton::DPadLeft) { buttons |= 0x0004; } - if controller.button(ControllerButton::DPadRight) { buttons |= 0x0008; } - if controller.button(ControllerButton::Start) { buttons |= 0x0010; } - if controller.button(ControllerButton::Back) { buttons |= 0x0020; } - if controller.button(ControllerButton::LeftStick) { buttons |= 0x0040; } - if controller.button(ControllerButton::RightStick) { buttons |= 0x0080; } - if controller.button(ControllerButton::LeftShoulder) { buttons |= 0x0100; } - if controller.button(ControllerButton::RightShoulder) { buttons |= 0x0200; } - if controller.button(ControllerButton::Guide) { buttons |= 0x0400; } - if controller.button(ControllerButton::A) { buttons |= 0x1000; } - if controller.button(ControllerButton::B) { buttons |= 0x2000; } - if controller.button(ControllerButton::X) { buttons |= 0x4000; } - if controller.button(ControllerButton::Y) { buttons |= 0x8000; } + if controller.button(ControllerButton::DPadUp) { + buttons |= 0x0001; + } + if controller.button(ControllerButton::DPadDown) { + buttons |= 0x0002; + } + if controller.button(ControllerButton::DPadLeft) { + buttons |= 0x0004; + } + if controller.button(ControllerButton::DPadRight) { + buttons |= 0x0008; + } + if controller.button(ControllerButton::Start) { + buttons |= 0x0010; + } + if controller.button(ControllerButton::Back) { + buttons |= 0x0020; + } + if controller.button(ControllerButton::LeftStick) { + buttons |= 0x0040; + } + if controller.button(ControllerButton::RightStick) { + buttons |= 0x0080; + } + if controller.button(ControllerButton::LeftShoulder) { + buttons |= 0x0100; + } + if controller.button(ControllerButton::RightShoulder) { + buttons |= 0x0200; + } + if controller.button(ControllerButton::Guide) { + buttons |= 0x0400; + } + if controller.button(ControllerButton::A) { + buttons |= 0x1000; + } + if controller.button(ControllerButton::B) { + buttons |= 0x2000; + } + if controller.button(ControllerButton::X) { + buttons |= 0x4000; + } + if controller.button(ControllerButton::Y) { + buttons |= 0x8000; + } buttons } fn axis_to_u8(value: i16) -> u8 { - (((value.max(0) as f32) / 32767.0) * 255.0).round().clamp(0.0, 255.0) as u8 + (((value.max(0) as f32) / 32767.0) * 255.0) + .round() + .clamp(0.0, 255.0) as u8 } fn map_scancode(code: Scancode) -> Option<(u16, u16)> { Some(match code { - Scancode::A => (0x41, 0x04), Scancode::B => (0x42, 0x05), Scancode::C => (0x43, 0x06), - Scancode::D => (0x44, 0x07), Scancode::E => (0x45, 0x08), Scancode::F => (0x46, 0x09), - Scancode::G => (0x47, 0x0A), Scancode::H => (0x48, 0x0B), Scancode::I => (0x49, 0x0C), - Scancode::J => (0x4A, 0x0D), Scancode::K => (0x4B, 0x0E), Scancode::L => (0x4C, 0x0F), - Scancode::M => (0x4D, 0x10), Scancode::N => (0x4E, 0x11), Scancode::O => (0x4F, 0x12), - Scancode::P => (0x50, 0x13), Scancode::Q => (0x51, 0x14), Scancode::R => (0x52, 0x15), - Scancode::S => (0x53, 0x16), Scancode::T => (0x54, 0x17), Scancode::U => (0x55, 0x18), - Scancode::V => (0x56, 0x19), Scancode::W => (0x57, 0x1A), Scancode::X => (0x58, 0x1B), - Scancode::Y => (0x59, 0x1C), Scancode::Z => (0x5A, 0x1D), - Scancode::Num1 => (0x31, 0x1E), Scancode::Num2 => (0x32, 0x1F), Scancode::Num3 => (0x33, 0x20), - Scancode::Num4 => (0x34, 0x21), Scancode::Num5 => (0x35, 0x22), Scancode::Num6 => (0x36, 0x23), - Scancode::Num7 => (0x37, 0x24), Scancode::Num8 => (0x38, 0x25), Scancode::Num9 => (0x39, 0x26), - Scancode::Num0 => (0x30, 0x27), Scancode::Return => (0x0D, 0x28), Scancode::Escape => (0x1B, 0x29), - Scancode::Backspace => (0x08, 0x2A), Scancode::Tab => (0x09, 0x2B), Scancode::Space => (0x20, 0x2C), - Scancode::Left => (0x25, 0x50), Scancode::Right => (0x27, 0x4F), Scancode::Up => (0x26, 0x52), Scancode::Down => (0x28, 0x51), - Scancode::LShift => (0xA0, 0xE1), Scancode::RShift => (0xA1, 0xE5), Scancode::LCtrl => (0xA2, 0xE0), Scancode::RCtrl => (0xA3, 0xE4), - Scancode::LAlt => (0xA4, 0xE2), Scancode::RAlt => (0xA5, 0xE6), Scancode::LGui => (0x5B, 0xE3), Scancode::RGui => (0x5C, 0xE7), - Scancode::F1 => (0x70, 0x3A), Scancode::F2 => (0x71, 0x3B), Scancode::F3 => (0x72, 0x3C), Scancode::F4 => (0x73, 0x3D), - Scancode::F5 => (0x74, 0x3E), Scancode::F6 => (0x75, 0x3F), Scancode::F7 => (0x76, 0x40), Scancode::F8 => (0x77, 0x41), - Scancode::F9 => (0x78, 0x42), Scancode::F10 => (0x79, 0x43), Scancode::F11 => (0x7A, 0x44), Scancode::F12 => (0x7B, 0x45), + Scancode::A => (0x41, 0x04), + Scancode::B => (0x42, 0x05), + Scancode::C => (0x43, 0x06), + Scancode::D => (0x44, 0x07), + Scancode::E => (0x45, 0x08), + Scancode::F => (0x46, 0x09), + Scancode::G => (0x47, 0x0A), + Scancode::H => (0x48, 0x0B), + Scancode::I => (0x49, 0x0C), + Scancode::J => (0x4A, 0x0D), + Scancode::K => (0x4B, 0x0E), + Scancode::L => (0x4C, 0x0F), + Scancode::M => (0x4D, 0x10), + Scancode::N => (0x4E, 0x11), + Scancode::O => (0x4F, 0x12), + Scancode::P => (0x50, 0x13), + Scancode::Q => (0x51, 0x14), + Scancode::R => (0x52, 0x15), + Scancode::S => (0x53, 0x16), + Scancode::T => (0x54, 0x17), + Scancode::U => (0x55, 0x18), + Scancode::V => (0x56, 0x19), + Scancode::W => (0x57, 0x1A), + Scancode::X => (0x58, 0x1B), + Scancode::Y => (0x59, 0x1C), + Scancode::Z => (0x5A, 0x1D), + Scancode::Num1 => (0x31, 0x1E), + Scancode::Num2 => (0x32, 0x1F), + Scancode::Num3 => (0x33, 0x20), + Scancode::Num4 => (0x34, 0x21), + Scancode::Num5 => (0x35, 0x22), + Scancode::Num6 => (0x36, 0x23), + Scancode::Num7 => (0x37, 0x24), + Scancode::Num8 => (0x38, 0x25), + Scancode::Num9 => (0x39, 0x26), + Scancode::Num0 => (0x30, 0x27), + Scancode::Return => (0x0D, 0x28), + Scancode::Escape => (0x1B, 0x29), + Scancode::Backspace => (0x08, 0x2A), + Scancode::Tab => (0x09, 0x2B), + Scancode::Space => (0x20, 0x2C), + Scancode::Left => (0x25, 0x50), + Scancode::Right => (0x27, 0x4F), + Scancode::Up => (0x26, 0x52), + Scancode::Down => (0x28, 0x51), + Scancode::LShift => (0xA0, 0xE1), + Scancode::RShift => (0xA1, 0xE5), + Scancode::LCtrl => (0xA2, 0xE0), + Scancode::RCtrl => (0xA3, 0xE4), + Scancode::LAlt => (0xA4, 0xE2), + Scancode::RAlt => (0xA5, 0xE6), + Scancode::LGui => (0x5B, 0xE3), + Scancode::RGui => (0x5C, 0xE7), + Scancode::F1 => (0x70, 0x3A), + Scancode::F2 => (0x71, 0x3B), + Scancode::F3 => (0x72, 0x3C), + Scancode::F4 => (0x73, 0x3D), + Scancode::F5 => (0x74, 0x3E), + Scancode::F6 => (0x75, 0x3F), + Scancode::F7 => (0x76, 0x40), + Scancode::F8 => (0x77, 0x41), + Scancode::F9 => (0x78, 0x42), + Scancode::F10 => (0x79, 0x43), + Scancode::F11 => (0x7A, 0x44), + Scancode::F12 => (0x7B, 0x45), _ => return None, }) } From 5d727896d3db336d4cc0dd7575c8d3c7b64d93e3 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:26:40 +0000 Subject: [PATCH 28/30] perf(streamer): reduce native playback latency --- .../src/main/services/streamerManager.ts | 194 ++++++++++++++---- opennow-streamer/src/window.rs | 12 +- 2 files changed, 166 insertions(+), 40 deletions(-) diff --git a/opennow-stable/src/main/services/streamerManager.ts b/opennow-stable/src/main/services/streamerManager.ts index aa10efee..04e859d0 100644 --- a/opennow-stable/src/main/services/streamerManager.ts +++ b/opennow-stable/src/main/services/streamerManager.ts @@ -38,10 +38,16 @@ export class StreamerManager { private server: Server | null = null; private socket: Socket | null = null; private process: ChildProcess | null = null; - private pendingReady: { resolve: () => void; reject: (error: Error) => void; timer: NodeJS.Timeout } | null = null; + private pendingReady: { + resolve: () => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; + } | null = null; private mode: "idle" | "legacy" | "external" = "idle"; - private settlePendingReady(outcome: { resolve: true } | { resolve: false; error: Error }): void { + private settlePendingReady( + outcome: { resolve: true } | { resolve: false; error: Error }, + ): void { const pending = this.pendingReady; if (!pending) { return; @@ -66,7 +72,10 @@ export class StreamerManager { getAvailability(): { available: boolean; reason?: string } { const binaryPath = this.resolveBinaryPath(); if (!existsSync(binaryPath)) { - return { available: false, reason: `Missing opennow-streamer binary at ${binaryPath}` }; + return { + available: false, + reason: `Missing opennow-streamer binary at ${binaryPath}`, + }; } return { available: true }; } @@ -78,7 +87,9 @@ export class StreamerManager { async start(request: ExternalStreamerLaunchRequest): Promise { const availability = this.getAvailability(); if (!availability.available) { - throw new Error(availability.reason ?? "Native streamer binary unavailable"); + throw new Error( + availability.reason ?? "Native streamer binary unavailable", + ); } await this.stop(); @@ -87,7 +98,11 @@ export class StreamerManager { const ready = this.waitForReady(); this.mode = "external"; this.emit({ type: "availability", available: true }); - this.emit({ type: "state", state: "connecting", detail: "launching native streamer" }); + this.emit({ + type: "state", + state: "connecting", + detail: "launching native streamer", + }); const runtimeEnv = { ...process.env }; if (process.platform === "linux") { @@ -104,10 +119,14 @@ export class StreamerManager { } runtimeEnv.RUST_BACKTRACE ??= "1"; - const child = spawn(binaryPath, ["--control-url", `tcp://127.0.0.1:${port}`], { - stdio: ["ignore", "pipe", "pipe"], - env: runtimeEnv, - }); + const child = spawn( + binaryPath, + ["--control-url", `tcp://127.0.0.1:${port}`], + { + stdio: ["ignore", "pipe", "pipe"], + env: runtimeEnv, + }, + ); this.process = child; child.stdout?.on("data", (chunk) => { @@ -121,7 +140,10 @@ export class StreamerManager { }); child.once("error", (error) => { - this.settlePendingReady({ resolve: false, error: new Error(`native streamer failed to launch: ${String(error)}`) }); + this.settlePendingReady({ + resolve: false, + error: new Error(`native streamer failed to launch: ${String(error)}`), + }); }); child.once("exit", (code, signal) => { @@ -138,11 +160,18 @@ export class StreamerManager { }); await ready; - await this.sendControl({ type: "configure", session: request.session, settings: request.settings }); + await this.sendControl({ + type: "configure", + session: request.session, + settings: request.settings, + }); } async stop(): Promise { - this.settlePendingReady({ resolve: false, error: new Error("native streamer startup cancelled") }); + this.settlePendingReady({ + resolve: false, + error: new Error("native streamer startup cancelled"), + }); if (this.socket && !this.socket.destroyed) { await this.sendControl({ type: "stop" }).catch(() => {}); } @@ -158,7 +187,9 @@ export class StreamerManager { this.mode = "idle"; } - async forwardSignalingEvent(event: MainToRendererSignalingEvent): Promise { + async forwardSignalingEvent( + event: MainToRendererSignalingEvent, + ): Promise { if (this.mode !== "external") { return false; } @@ -208,23 +239,46 @@ export class StreamerManager { const repoCandidates = repoRoots.flatMap((root) => app.isPackaged ? [ - resolve(root, `opennow-streamer/target/release/opennow-streamer${suffix}`), - resolve(root, `opennow-streamer/target/debug/opennow-streamer${suffix}`), + resolve( + root, + `opennow-streamer/target/release/opennow-streamer${suffix}`, + ), + resolve( + root, + `opennow-streamer/target/debug/opennow-streamer${suffix}`, + ), ] : [ - resolve(root, `opennow-streamer/target/debug/opennow-streamer${suffix}`), - resolve(root, `opennow-streamer/target/release/opennow-streamer${suffix}`), + resolve( + root, + `opennow-streamer/target/debug/opennow-streamer${suffix}`, + ), + resolve( + root, + `opennow-streamer/target/release/opennow-streamer${suffix}`, + ), ], ); const candidates = [ ...(envOverride ? [envOverride] : []), - resolve(mainDir, `../../../../opennow-streamer/target/release/opennow-streamer${suffix}`), - resolve(mainDir, `../../../../opennow-streamer/target/debug/opennow-streamer${suffix}`), + resolve( + mainDir, + `../../../../opennow-streamer/target/release/opennow-streamer${suffix}`, + ), + resolve( + mainDir, + `../../../../opennow-streamer/target/debug/opennow-streamer${suffix}`, + ), ...repoCandidates, join(process.resourcesPath, "bin", `opennow-streamer${suffix}`), - resolve(app.getAppPath(), `../opennow-streamer/target/release/opennow-streamer${suffix}`), + resolve( + app.getAppPath(), + `../opennow-streamer/target/release/opennow-streamer${suffix}`, + ), ]; - return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]; + return ( + candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] + ); } private async createControlServer(): Promise { @@ -253,10 +307,20 @@ export class StreamerManager { if (this.socket === socket) { this.socket = null; } - this.settlePendingReady({ resolve: false, error: new Error("native streamer control socket closed before handshake") }); + this.settlePendingReady({ + resolve: false, + error: new Error( + "native streamer control socket closed before handshake", + ), + }); }); socket.on("error", (error) => { - this.settlePendingReady({ resolve: false, error: new Error(`native streamer control socket error: ${String(error)}`) }); + this.settlePendingReady({ + resolve: false, + error: new Error( + `native streamer control socket error: ${String(error)}`, + ), + }); }); }); @@ -276,7 +340,10 @@ export class StreamerManager { private waitForReady(): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { - this.settlePendingReady({ resolve: false, error: new Error("Timed out waiting for native streamer handshake") }); + this.settlePendingReady({ + resolve: false, + error: new Error("Timed out waiting for native streamer handshake"), + }); }, 10_000); this.pendingReady = { resolve, reject, timer }; }); @@ -292,16 +359,41 @@ export class StreamerManager { throw new Error("Native streamer control channel is not connected"); } await new Promise((resolve, reject) => { - this.socket?.write(`${JSON.stringify(message)}\n`, (error) => (error ? reject(error) : resolve())); + this.socket?.write(`${JSON.stringify(message)}\n`, (error) => + error ? reject(error) : resolve(), + ); }); } + private shouldForwardLog( + level: string | undefined, + message: string | undefined, + ): boolean { + if (!message) { + return false; + } + if (process.env.OPENNOW_STREAMER_VERBOSE === "1") { + return true; + } + if (level === "debug") { + return false; + } + return ![ + "remote data channel control_channel:", + "remote data channel remote_trace_channel:", + "input channel message ", + ].some((prefix) => message.startsWith(prefix)); + } + private handleProcessMessage(line: string): void { let parsed: StreamerProcessMessage; try { parsed = JSON.parse(line) as StreamerProcessMessage; } catch (error) { - this.emit({ type: "error", message: `Invalid native streamer payload: ${String(error)}` }); + this.emit({ + type: "error", + message: `Invalid native streamer payload: ${String(error)}`, + }); return; } @@ -309,17 +401,31 @@ export class StreamerManager { if (this.pendingReady) { this.settlePendingReady({ resolve: true }); } - this.emit({ type: "log", level: "info", message: `native streamer connected pid=${parsed.pid ?? "unknown"}` }); + this.emit({ + type: "log", + level: "info", + message: `native streamer connected pid=${parsed.pid ?? "unknown"}`, + }); return; } if (parsed.type === "log") { - this.emit({ type: "log", level: parsed.level ?? "info", message: parsed.message ?? "" }); + if (this.shouldForwardLog(parsed.level, parsed.message)) { + this.emit({ + type: "log", + level: parsed.level ?? "info", + message: parsed.message ?? "", + }); + } return; } if (parsed.type === "state") { - this.emit({ type: "state", state: parsed.state ?? "connecting", detail: parsed.detail }); + this.emit({ + type: "state", + state: parsed.state ?? "connecting", + detail: parsed.detail, + }); return; } @@ -329,9 +435,14 @@ export class StreamerManager { level: "info", message: `forwarding native answer (${parsed.sdp.length} chars, nvst=${parsed.nvstSdp?.length ?? 0} chars)`, }); - void this.signalingHandlers.sendAnswer({ sdp: parsed.sdp, nvstSdp: parsed.nvstSdp }).catch((error) => { - this.emit({ type: "error", message: `Failed to forward native answer: ${String(error)}` }); - }); + void this.signalingHandlers + .sendAnswer({ sdp: parsed.sdp, nvstSdp: parsed.nvstSdp }) + .catch((error) => { + this.emit({ + type: "error", + message: `Failed to forward native answer: ${String(error)}`, + }); + }); return; } @@ -341,13 +452,18 @@ export class StreamerManager { level: "info", message: `forwarding native ICE candidate (mid=${parsed.sdpMid ?? "null"}, mline=${parsed.sdpMLineIndex ?? "null"})`, }); - void this.signalingHandlers.sendIceCandidate({ - candidate: parsed.candidate, - sdpMid: parsed.sdpMid, - sdpMLineIndex: parsed.sdpMLineIndex, - }).catch((error) => { - this.emit({ type: "error", message: `Failed to forward native ICE: ${String(error)}` }); - }); + void this.signalingHandlers + .sendIceCandidate({ + candidate: parsed.candidate, + sdpMid: parsed.sdpMid, + sdpMLineIndex: parsed.sdpMLineIndex, + }) + .catch((error) => { + this.emit({ + type: "error", + message: `Failed to forward native ICE: ${String(error)}`, + }); + }); } } diff --git a/opennow-streamer/src/window.rs b/opennow-streamer/src/window.rs index 59b5d732..690315f9 100644 --- a/opennow-streamer/src/window.rs +++ b/opennow-streamer/src/window.rs @@ -1,5 +1,6 @@ use std::{ collections::{HashMap, HashSet}, + mem::size_of, sync::mpsc::Receiver, time::Duration, }; @@ -386,7 +387,16 @@ fn slot_for_instance( } fn queue_audio(queue: &AudioQueue, frame: AudioFrame) { - let _ = (frame.channels, frame.sample_rate); + let bytes_per_sample = size_of::() as u32; + let bytes_per_second = frame + .sample_rate + .saturating_mul(frame.channels.max(1) as u32) + .saturating_mul(bytes_per_sample); + let max_queued_bytes = + (bytes_per_second / 20).max(bytes_per_sample * frame.samples.len() as u32); + if queue.size() > max_queued_bytes { + queue.clear(); + } let _ = queue.queue_audio(&frame.samples); } From 36ff4f813d3be0f68866e60ee9ef886f376fabbd Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:59:48 +0000 Subject: [PATCH 29/30] perf(streamer): optimize mac decode upload path --- opennow-streamer/src/media.rs | 55 +++++++++++++++++------- opennow-streamer/src/window.rs | 77 ++++++++++++++++++++++++---------- 2 files changed, 95 insertions(+), 37 deletions(-) diff --git a/opennow-streamer/src/media.rs b/opennow-streamer/src/media.rs index 4adb4c70..e519bda7 100644 --- a/opennow-streamer/src/media.rs +++ b/opennow-streamer/src/media.rs @@ -26,9 +26,14 @@ use crate::messages::StreamerMessage; pub struct VideoFrame { pub width: u32, pub height: u32, - pub y_plane: Vec, - pub u_plane: Vec, - pub v_plane: Vec, + pub pixel_format: VideoPixelFormat, + pub data: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VideoPixelFormat { + I420, + Nv12, } #[derive(Clone)] @@ -359,6 +364,7 @@ struct DecoderCandidate { name: String, input_args: Vec, output_args: Vec, + output_pixel_format: VideoPixelFormat, } fn spawn_decoder_process( @@ -382,7 +388,10 @@ fn spawn_decoder_process( .arg("-sn") .arg("-dn") .arg("-pix_fmt") - .arg("yuv420p") + .arg(match candidate.output_pixel_format { + VideoPixelFormat::I420 => "yuv420p", + VideoPixelFormat::Nv12 => "nv12", + }) .arg("-f") .arg("rawvideo") .arg("pipe:1") @@ -394,9 +403,11 @@ fn spawn_decoder_process( .spawn() .with_context(|| format!("spawn ffmpeg decoder backend {}", candidate.name))?; - let y_size = (width * height) as usize; + let y_size = (width * height) as usize; let uv_size = ((width / 2) * (height / 2)) as usize; - let frame_size = y_size + uv_size + uv_size; + let frame_size = match candidate.output_pixel_format { + VideoPixelFormat::I420 | VideoPixelFormat::Nv12 => y_size + uv_size + uv_size, + }; let mut stdout = child .stdout .take() @@ -406,6 +417,7 @@ fn spawn_decoder_process( .take() .ok_or_else(|| anyhow!("missing ffmpeg stderr"))?; let backend_name = candidate.name.clone(); + let output_pixel_format = candidate.output_pixel_format; send_streamer_log( &log_tx, "info", @@ -414,15 +426,12 @@ fn spawn_decoder_process( thread::spawn(move || { let mut buffer = vec![0_u8; frame_size]; while stdout.read_exact(&mut buffer).is_ok() { - let y_plane = buffer[..y_size].to_vec(); - let u_plane = buffer[y_size..(y_size + uv_size)].to_vec(); - let v_plane = buffer[(y_size + uv_size)..].to_vec(); + let frame = std::mem::replace(&mut buffer, vec![0_u8; frame_size]); let _ = event_tx.send(MediaEvent::Video(VideoFrame { width, height, - y_plane, - u_plane, - v_plane, + pixel_format: output_pixel_format, + data: frame, })); } }); @@ -471,6 +480,7 @@ fn build_decoder_candidates(ffmpeg: &Path, demuxer: &str) -> Vec Vec "hevc_videotoolbox", + "h264" => "h264_videotoolbox", + _ => "hevc_videotoolbox", + }; candidates.push(DecoderCandidate { name: "macos-videotoolbox-copyback".into(), - input_args: base_ffmpeg_input_args(&["-hwaccel", "videotoolbox"]), - output_args: ffmpeg_output_args(&["-vf", "format=yuv420p"]), + input_args: base_ffmpeg_input_args(&[ + "-hwaccel", + "videotoolbox", + "-c:v", + decoder, + ]), + output_args: ffmpeg_output_args(&["-pix_fmt", "nv12"]), + output_pixel_format: VideoPixelFormat::Nv12, }); } @@ -514,6 +536,7 @@ fn build_decoder_candidates(ffmpeg: &Path, demuxer: &str) -> Vec Vec Vec Vec Vec = canvas.texture_creator(); let mut texture = texture_creator - .create_texture_streaming(PixelFormatEnum::IYUV, width, height) + .create_texture_streaming(PixelFormatEnum::NV12, width, height) .context("create texture")?; texture.set_blend_mode(BlendMode::None); let mut texture_width = width; let mut texture_height = height; + let mut texture_format = VideoPixelFormat::Nv12; let queue: AudioQueue = audio .open_queue::( @@ -107,44 +109,73 @@ pub fn run( let mut connected_slots = HashSet::::new(); let mut mouse_captured = false; let mut running = true; + let mut frame_dirty = false; while running { while let Ok(event) = media_rx.try_recv() { match event { - MediaEvent::Video(frame) => latest_frame = Some(frame), + MediaEvent::Video(frame) => { + latest_frame = Some(frame); + frame_dirty = true; + } MediaEvent::Audio(frame) => queue_audio(&queue, frame), } } if let Some(frame) = latest_frame.take() { - if frame.width != texture_width || frame.height != texture_height { + if frame.width != texture_width + || frame.height != texture_height + || frame.pixel_format != texture_format + { + let pixel_format = match frame.pixel_format { + VideoPixelFormat::I420 => PixelFormatEnum::IYUV, + VideoPixelFormat::Nv12 => PixelFormatEnum::NV12, + }; texture = texture_creator - .create_texture_streaming(PixelFormatEnum::IYUV, frame.width, frame.height) + .create_texture_streaming(pixel_format, frame.width, frame.height) .context("recreate texture")?; texture.set_blend_mode(BlendMode::None); texture_width = frame.width; texture_height = frame.height; + texture_format = frame.pixel_format; let window = canvas.window_mut(); let _ = window.set_size(frame.width.max(640), frame.height.max(360)); } - texture - .update_yuv( - None, - &frame.y_plane, - frame.width as usize, - &frame.u_plane, - (frame.width / 2) as usize, - &frame.v_plane, - (frame.width / 2) as usize, - ) - .context("texture update yuv")?; + match frame.pixel_format { + VideoPixelFormat::I420 => { + let y_len = (frame.width * frame.height) as usize; + let uv_len = ((frame.width / 2) * (frame.height / 2)) as usize; + let y_plane = &frame.data[..y_len]; + let u_plane = &frame.data[y_len..(y_len + uv_len)]; + let v_plane = &frame.data[(y_len + uv_len)..(y_len + uv_len + uv_len)]; + texture + .update_yuv( + None, + y_plane, + frame.width as usize, + u_plane, + (frame.width / 2) as usize, + v_plane, + (frame.width / 2) as usize, + ) + .context("texture update yuv")?; + } + VideoPixelFormat::Nv12 => { + texture + .update(None, &frame.data, frame.width as usize) + .context("texture update nv12")?; + } + } } - canvas.clear(); - let (out_w, out_h) = canvas.output_size().unwrap_or((width, height)); - canvas - .copy(&texture, None, Some(Rect::new(0, 0, out_w, out_h))) - .ok(); - canvas.present(); + if frame_dirty { + canvas.clear(); + let (out_w, out_h) = canvas.output_size().unwrap_or((width, height)); + canvas + .copy(&texture, None, Some(Rect::new(0, 0, out_w, out_h))) + .ok(); + canvas.present(); + frame_dirty = false; + } for event in event_pump.poll_iter() { match event { @@ -306,7 +337,7 @@ pub fn run( _ => {} } } - std::thread::sleep(Duration::from_millis(1)); + std::thread::sleep(Duration::from_millis(4)); } Ok(()) From bf0c8dec2b90a0972d935b176b36562916f7f9f3 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:06:50 +0000 Subject: [PATCH 30/30] fix(streamer): probe mac videotoolbox decoder support --- opennow-streamer/src/media.rs | 66 ++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/opennow-streamer/src/media.rs b/opennow-streamer/src/media.rs index e519bda7..fb85e5cf 100644 --- a/opennow-streamer/src/media.rs +++ b/opennow-streamer/src/media.rs @@ -403,7 +403,7 @@ fn spawn_decoder_process( .spawn() .with_context(|| format!("spawn ffmpeg decoder backend {}", candidate.name))?; - let y_size = (width * height) as usize; + let y_size = (width * height) as usize; let uv_size = ((width / 2) * (height / 2)) as usize; let frame_size = match candidate.output_pixel_format { VideoPixelFormat::I420 | VideoPixelFormat::Nv12 => y_size + uv_size + uv_size, @@ -466,6 +466,7 @@ fn send_streamer_log( fn build_decoder_candidates(ffmpeg: &Path, demuxer: &str) -> Vec { let hwaccels = query_hwaccels(ffmpeg); + let _decoders = query_decoders(ffmpeg); let mut candidates = Vec::new(); #[cfg(target_os = "windows")] @@ -505,17 +506,34 @@ fn build_decoder_candidates(ffmpeg: &Path, demuxer: &str) -> Vec "h264_videotoolbox", _ => "hevc_videotoolbox", }; - candidates.push(DecoderCandidate { - name: "macos-videotoolbox-copyback".into(), - input_args: base_ffmpeg_input_args(&[ - "-hwaccel", - "videotoolbox", - "-c:v", - decoder, - ]), - output_args: ffmpeg_output_args(&["-pix_fmt", "nv12"]), - output_pixel_format: VideoPixelFormat::Nv12, - }); + if _decoders.contains(decoder) { + candidates.push(DecoderCandidate { + name: "macos-videotoolbox-native-decoder".into(), + input_args: base_ffmpeg_input_args(&[ + "-hwaccel", + "videotoolbox", + "-hwaccel_output_format", + "videotoolbox", + "-c:v", + decoder, + ]), + output_args: ffmpeg_output_args(&["-pix_fmt", "nv12"]), + output_pixel_format: VideoPixelFormat::Nv12, + }); + } + if hwaccels.contains("videotoolbox") || _decoders.contains(decoder) || hwaccels.is_empty() { + candidates.push(DecoderCandidate { + name: "macos-videotoolbox-copyback".into(), + input_args: base_ffmpeg_input_args(&[ + "-hwaccel", + "videotoolbox", + "-hwaccel_output_format", + "videotoolbox", + ]), + output_args: ffmpeg_output_args(&["-pix_fmt", "nv12"]), + output_pixel_format: VideoPixelFormat::Nv12, + }); + } } #[cfg(target_os = "linux")] @@ -639,6 +657,30 @@ fn ffmpeg_output_args(extra: &[&str]) -> Vec { args } +fn query_decoders(ffmpeg: &Path) -> HashSet { + Command::new(ffmpeg) + .arg("-hide_banner") + .arg("-decoders") + .output() + .ok() + .map(|output| { + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + let trimmed = line.trim_start(); + if trimmed.len() < 3 || !trimmed.starts_with('V') { + return None; + } + trimmed + .split_whitespace() + .nth(1) + .map(|name| name.to_lowercase()) + }) + .collect::>() + }) + .unwrap_or_default() +} + fn query_hwaccels(ffmpeg: &Path) -> HashSet { Command::new(ffmpeg) .arg("-hide_banner")