This commit is contained in:
2026-04-01 11:50:32 +02:00
parent c2d1587c82
commit 945115527f
13 changed files with 379 additions and 12 deletions

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "tauri-video03" name = "tauri-video03"
version = "0.1.0" version = "0.1.1"
description = "A Tauri Video App" description = "A Tauri Video App"
authors = ["sinus@sasedev.net"] authors = ["sinus@sasedev.net"]
edition = "2024" edition = "2024"
@@ -28,10 +28,11 @@ serde = { version = "^1.0", features = ["derive"] }
serde_json = { version = "^1.0", features = [] } serde_json = { version = "^1.0", features = [] }
tauri = { version = "^2.10", features = ["default"] } tauri = { version = "^2.10", features = ["default"] }
tauri-plugin-opener = { version = "^2.5", features = [] } tauri-plugin-opener = { version = "^2.5", features = [] }
tauri-plugin-websocket = "2"
tokio = { version = "^1.50", features = ["full"] } tokio = { version = "^1.50", features = ["full"] }
tokio-tungstenite = { version = "^0.29", features = ["rustls", "tokio-rustls", "url"] } tokio-tungstenite = { version = "^0.29", features = ["rustls", "tokio-rustls", "url"] }
tracing = { version = "^0.1", features = ["async-await", "log"] } tracing = { version = "^0.1", features = ["async-await", "log"] }
tracing-subscriber = { version = "^0.3", features = ["ansi", "env-filter", "chrono", "serde", "json"] } tracing-subscriber = { version = "^0.3", features = ["ansi", "env-filter", "chrono", "serde", "json"] }
ts-rs = { version = "^12.0", features = [] } ts-rs = { version = "^12.0", features = [] }
uuid = { version = "^1.23", features = [] } uuid = { version = "^1.23", features = ["v4"] }
webrtc = { version = "^0.17", features = [] } webrtc = { version = "^0.17", features = [] }

View File

@@ -2,9 +2,12 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main"], "windows": [
"main"
],
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default" "opener:default",
"websocket:default"
] ]
} }

198
frontend/chat.ts Normal file
View File

@@ -0,0 +1,198 @@
import WebSocket from "@tauri-apps/plugin-websocket";
import { PeerInfo } from './ts/bindings/PeerInfo';
import { ClientWsMessage } from './ts/bindings/ClientWsMessage';
import { ServerWsMessage } from './ts/bindings/ServerWsMessage';
type RemoveListener = (() => void) | null;
export class LocalSignallingClient {
private socket: WebSocket | null = null;
private removeListener: RemoveListener = null;
private myPeerId: string | null = null;
private peers: PeerInfo[] = [];
constructor(
private readonly displayName: string,
private readonly onLog: (message: string) => void,
private readonly onPeers: (peers: PeerInfo[]) => void,
private readonly onChat: (line: string) => void,
) {}
public async connect(): Promise<void> {
if (this.socket !== null) {
this.onLog("signalling already connected");
return;
}
const url = "ws://127.0.0.1:3012";
this.onLog(`connecting to ${url}`);
let socket: WebSocket;
try {
socket = await WebSocket.connect(url);
} catch (error) {
throw new Error(`websocket connection failed: ${String(error)}`);
}
this.socket = socket;
const removeListener = socket.addListener((message) => {
if (message.type === "Text") {
this.handleServerMessage(message.data);
return;
}
if (message.type === "Close") {
this.onLog("signalling disconnected");
this.cleanupDisconnectedState();
return;
}
if (message.type === "Binary") {
this.onLog("unexpected binary websocket message");
return;
}
});
this.removeListener = removeListener;
this.onLog("signalling connected");
await this.send({
type: "hello",
display_name: this.displayName,
});
}
public async disconnect(): Promise<void> {
if (this.socket === null) {
return;
}
const socket = this.socket;
if (this.removeListener !== null) {
this.removeListener();
this.removeListener = null;
}
this.socket = null;
try {
await socket.disconnect();
} catch (error) {
this.onLog(`disconnect error: ${String(error)}`);
}
this.cleanupDisconnectedState();
}
public async sendChat(text: string): Promise<void> {
await this.send({
type: "chat_send",
text,
});
}
public getMyPeerId(): string | null {
return this.myPeerId;
}
public getPeers(): PeerInfo[] {
return [...this.peers];
}
private async send(message: ClientWsMessage): Promise<void> {
if (this.socket === null) {
this.onLog("cannot send: signalling not connected");
return;
}
const payload = JSON.stringify(message);
try {
await this.socket.send(payload);
} catch (error) {
this.onLog(`send failed: ${String(error)}`);
}
}
private cleanupDisconnectedState(): void {
this.socket = null;
this.myPeerId = null;
this.peers = [];
this.onPeers([]);
}
private handleServerMessage(raw: string): void {
let message: ServerWsMessage;
try {
message = JSON.parse(raw) as ServerWsMessage;
} catch (error) {
this.onLog(`invalid server message: ${String(error)}`);
return;
}
switch (message.type) {
case "welcome":
this.myPeerId = message.peer_id;
this.onLog(`welcome peer_id=${message.peer_id}`);
break;
case "peer_list":
this.peers = message.peers;
this.onPeers([...this.peers]);
this.onLog(`peer_list received (${message.peers.length})`);
break;
case "peer_joined":
this.peers = mergePeer(this.peers, message.peer);
this.onPeers([...this.peers]);
this.onLog(`peer joined: ${message.peer.display_name}`);
break;
case "peer_left":
this.peers = this.peers.filter((peer) => peer.peer_id !== message.peer_id);
this.onPeers([...this.peers]);
this.onLog(`peer left: ${message.peer_id}`);
break;
case "chat_receive":
this.onChat(`${message.from_display_name}: ${message.text}`);
break;
case "offer":
this.onLog(`offer received from ${message.from_peer_id}`);
break;
case "answer":
this.onLog(`answer received from ${message.from_peer_id}`);
break;
case "ice_candidate":
this.onLog(`ice candidate received from ${message.from_peer_id}`);
break;
case "pong":
this.onLog("pong");
break;
case "error":
this.onLog(`server error: ${message.message}`);
break;
default:
this.onLog(`unhandled server message: ${raw}`);
break;
}
}
}
function mergePeer(peers: PeerInfo[], incoming: PeerInfo): PeerInfo[] {
const next = peers.filter((peer) => peer.peer_id !== incoming.peer_id);
next.push(incoming);
next.sort((a, b) => a.display_name.localeCompare(b.display_name));
return next;
}

View File

@@ -6,12 +6,43 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" <meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self';" /> content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self';" />
<title>Tauri GST Record + Preview</title> <title>Tauri GST Signalling + Chat</title>
</head> </head>
<body> <body>
<main class="container"> <main class="container">
<h1>POC 2 - Recording + Preview</h1> <h1>POC 3 - Chat</h1>
<section class="card">
<h2>Signalling + Chat</h2>
<div class="actions">
<input id="chat-display-name" type="text" placeholder="Display name"
value="sinus" />
<button id="chat-connect-btn" type="button">Connect
signalling</button>
<button id="chat-disconnect-btn" type="button" disabled>Disconnect</button>
</div>
<div class="chat-grid">
<div>
<h3>Peers</h3>
<pre id="chat-peers">[]</pre>
</div>
<div>
<h3>Logs</h3>
<pre id="chat-logs">Ready.</pre>
</div>
</div>
<div class="actions">
<input id="chat-input" type="text" placeholder="Message" />
<button id="chat-send-btn" type="button" disabled>Send chat</button>
</div>
<pre id="chat-messages">No messages yet.</pre>
</section>
<section class="card"> <section class="card">
<h2>Video Preview</h2> <h2>Video Preview</h2>

View File

@@ -2,6 +2,8 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { AvStartResponse } from './ts/bindings/AvStartResponse'; import { AvStartResponse } from './ts/bindings/AvStartResponse';
import { AvStopResponse } from './ts/bindings/AvStopResponse'; import { AvStopResponse } from './ts/bindings/AvStopResponse';
import { LocalSignallingClient } from "./chat";
import { PeerInfo } from './ts/bindings/PeerInfo';
type Mode = "idle" | "audio" | "video" | "av"; type Mode = "idle" | "audio" | "video" | "av";
@@ -19,6 +21,15 @@ const avStatus = document.querySelector<HTMLElement>("#av-status");
const previewElement = document.querySelector<HTMLImageElement>("#video-preview"); const previewElement = document.querySelector<HTMLImageElement>("#video-preview");
const chatDisplayNameInput = document.querySelector<HTMLInputElement>("#chat-display-name");
const chatConnectBtn = document.querySelector<HTMLButtonElement>("#chat-connect-btn");
const chatDisconnectBtn = document.querySelector<HTMLButtonElement>("#chat-disconnect-btn");
const chatInput = document.querySelector<HTMLInputElement>("#chat-input");
const chatSendBtn = document.querySelector<HTMLButtonElement>("#chat-send-btn");
const chatPeers = document.querySelector<HTMLElement>("#chat-peers");
const chatLogs = document.querySelector<HTMLElement>("#chat-logs");
const chatMessages = document.querySelector<HTMLElement>("#chat-messages");
if ( if (
startAudioBtn === null || startAudioBtn === null ||
stopAudioBtn === null || stopAudioBtn === null ||
@@ -29,7 +40,15 @@ if (
startAvBtn === null || startAvBtn === null ||
stopAvBtn === null || stopAvBtn === null ||
avStatus === null || avStatus === null ||
previewElement === null previewElement === null ||
chatDisplayNameInput === null ||
chatConnectBtn === null ||
chatDisconnectBtn === null ||
chatInput === null ||
chatSendBtn === null ||
chatPeers === null ||
chatLogs === null ||
chatMessages === null
) { ) {
throw new Error("missing UI elements"); throw new Error("missing UI elements");
} }
@@ -320,3 +339,89 @@ stopAvBtn.addEventListener("click", async () => {
}); });
setCurrentMode("idle"); setCurrentMode("idle");
let signallingClient: LocalSignallingClient | null = null;
function appendChatLog(line: string): void {
if (chatLogs) {
const current = chatLogs.textContent ?? "";
chatLogs.textContent = `${current}\n${line}`.trim();
}
}
function appendChatMessage(line: string): void {
if (chatMessages) {
const current = chatMessages.textContent ?? "";
chatMessages.textContent = `${current}\n${line}`.trim();
}
}
function updatePeerList(peers: PeerInfo[]): void {
if (chatPeers)
chatPeers.textContent = JSON.stringify(peers, null, 2);
}
function setChatConnected(connected: boolean): void {
if (chatConnectBtn)
chatConnectBtn.disabled = connected;
if (chatDisconnectBtn)
chatDisconnectBtn.disabled = !connected;
if (chatSendBtn)
chatSendBtn.disabled = !connected;
}
chatConnectBtn.addEventListener("click", async () => {
if (signallingClient !== null) {
appendChatLog("signalling client already exists");
return;
}
const displayName = (chatDisplayNameInput.value || "").trim() || "anonymous";
const client = new LocalSignallingClient(
displayName,
(line) => appendChatLog(line),
(peers) => updatePeerList(peers),
(line) => appendChatMessage(line),
);
try {
await client.connect();
signallingClient = client;
setChatConnected(true);
} catch (error) {
appendChatLog(`connect failed: ${String(error)}`);
signallingClient = null;
setChatConnected(false);
}
});
chatDisconnectBtn.addEventListener("click", () => {
if (signallingClient === null) {
return;
}
signallingClient.disconnect();
signallingClient = null;
setChatConnected(false);
updatePeerList([]);
});
chatSendBtn.addEventListener("click", () => {
if (signallingClient === null) {
appendChatLog("not connected");
return;
}
const text = chatInput.value.trim();
if (text.length === 0) {
appendChatLog("chat message is empty");
return;
}
signallingClient.sendChat(text);
chatInput.value = "";
});
setChatConnected(false);

View File

@@ -71,3 +71,19 @@ pre {
padding: 12px; padding: 12px;
white-space: pre-wrap; white-space: pre-wrap;
} }
.chat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
input[type="text"] {
border: 1px solid #374151;
border-radius: 8px;
padding: 10px 12px;
background: #0b1220;
color: #f9fafb;
min-width: 220px;
}

View File

@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ClientWsMessage = { "type": "hello", display_name: string, } | { "type": "chat_send", text: string, } | { "type": "offer", target_peer_id: string, sdp: string, } | { "type": "answer", target_peer_id: string, sdp: string, } | { "type": "ice_candidate", target_peer_id: string, candidate: string, sdp_mid: string | null, sdp_mline_index: number | null, } | { "type": "ping" };

View File

@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PeerInfo = { peer_id: string, display_name: string, };

View File

@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PeerInfo } from "./PeerInfo";
export type ServerWsMessage = { "type": "welcome", peer_id: string, } | { "type": "peer_list", peers: Array<PeerInfo>, } | { "type": "peer_joined", peer: PeerInfo, } | { "type": "peer_left", peer_id: string, } | { "type": "chat_receive", from_peer_id: string, from_display_name: string, text: string, } | { "type": "offer", from_peer_id: string, sdp: string, } | { "type": "answer", from_peer_id: string, sdp: string, } | { "type": "ice_candidate", from_peer_id: string, candidate: string, sdp_mid: string | null, sdp_mline_index: number | null, } | { "type": "pong" } | { "type": "error", message: string, };

View File

@@ -1,7 +1,7 @@
{ {
"name": "tauri-video03", "name": "tauri-video03",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -11,7 +11,8 @@
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.10", "@tauri-apps/api": "^2.10",
"@tauri-apps/plugin-opener": "^2.5" "@tauri-apps/plugin-opener": "^2.5",
"@tauri-apps/plugin-websocket": "^2.4"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.10", "@tauri-apps/cli": "^2.10",

View File

@@ -33,6 +33,7 @@ pub fn run() {
init_gstreamer(); init_gstreamer();
let builder = tauri::Builder::default() let builder = tauri::Builder::default()
.plugin(tauri_plugin_websocket::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.manage(app_state::AppState::new()) .manage(app_state::AppState::new())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "tauri-video03", "productName": "tauri-video03",
"version": "0.1.0", "version": "0.1.1",
"identifier": "com.sinus.tauri-video03", "identifier": "com.sinus.tauri-video03",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@@ -7,6 +7,7 @@ const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig(async () => ({ export default defineConfig(async () => ({
envPrefix: ['VITE_', 'TAURI_ENV_*'],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// //
// 1. prevent Vite from obscuring rust errors // 1. prevent Vite from obscuring rust errors
@@ -61,7 +62,7 @@ export default defineConfig(async () => ({
// 2. tauri expects a fixed port, fail if that port is not available // 2. tauri expects a fixed port, fail if that port is not available
server: { server: {
port: 1420, port: 1420,
strictPort: true, strictPort: false,
host: host || false, host: host || false,
hmr: host hmr: host
? { ? {