tauri2-guide/tauri2-docs/ws_server_client_inject_guide.md
Z User 5972c96ff9 Comprehensive improvements across all guide documents
definitive-guide.md:
- Add missing AsyncReadExt import in streaming example
- Fix v1/v2 WindowUrl table entry (v1 used WindowUrl not WebviewUrl)
- Rename 3-Step Pattern to 4-Step Pattern for plugins

agent-prompts.md:
- Add new Section 15: Prompt for debugging build/compile errors

error-resolution-guide.md:
- Improve A3 lib naming convention explanation
- Clarify G3 multiple definition root cause
- Add actionable WebView2 install commands to H1
- Add O9-O11 mobile build errors (Android SDK, macOS, CocoaPods)

cheatsheet.md:
- Add deep linking subsection with plugin example
- Add gen/schemas/ to file hierarchy
- Fix tokio::spawn -> tauri::async_runtime::spawn in WS example
- Bump tokio-tungstenite 0.21 -> 0.24

ws_server_client_inject_guide.md:
- Add WebSocket.OPEN guard to sendToAllWindows()

tauri2-links.md:
- Expand from 6 to 16 official documentation links
2026-05-30 20:45:20 +00:00

559 lines
21 KiB
Markdown

# WebSocket Server, Client & Script Injection Guide
> A comprehensive guide for building native WebSocket servers, frontend clients, and script injection patterns in Tauri 2.0 applications.
> This guide documents the complete pattern for running a native WebSocket server inside Tauri 2.0, connecting frontend clients, and injecting scripts into external webview windows.
---
## Overview
This guide covers three interrelated patterns for Tauri 2.0 applications:
1. **Native WebSocket Server** — A Tokio-backed WebSocket server running inside the Tauri process
2. **Frontend WebSocket Client** — JavaScript clients connecting to the local server for bidirectional communication
3. **Script Injection** — Injecting JavaScript into dynamically created webview windows (including external URLs) to bridge them into the WebSocket network
### Architecture Diagram
```text
┌─────────────────────────────────────────────────────────┐
│ Tauri Process │
│ │
│ ┌──────────────┐ ┌──────────────────────────────┐ │
│ │ lib.rs │ │ Tokio WebSocket Server │ │
│ │ │────▶│ (ws://127.0.0.1:8080) │ │
│ │ .setup() │ │ │ │
│ │ spawns │ │ broadcast channel (16 msg) │ │
│ │ server │ │ │ │
│ └──────────────┘ └───────────┬───────────────────┘ │
│ │ │
│ ┌─────────────────────────┼─────────────────┐ │
│ │ │ │ │
│ ┌──────▼──────┐ ┌───────────────▼──────────┐ │ │
│ │ Main Window │ │ Remote Window │ │ │
│ │ (index.html) │ │ (external URL) │ │ │
│ │ │ │ + injected script │ │ │
│ │ WebSocket │ │ + WebSocket client │ │ │
│ │ Client (JS) │ │ Client (injected JS) │ │ │
│ └──────┬──────┘ └───────────────┬──────────┘ │ │
└─────────┼─────────────────────────┼────────────────┘ │
│ │ │
│ WebSocket (local) │ │
└─────────────────────────┘ │
```
---
## Part 1: Native WebSocket Server (Rust)
### Why Run a WebSocket Server Inside Tauri?
Standard Tauri IPC (`invoke`, events, channels) only works between Tauri's own windows and its Rust backend. When you need to:
- Communicate with **external applications** running on the same machine
- Bridge **external URLs** loaded in webview windows back to the Tauri backend
- Enable **cross-window communication** at scale (beyond Tauri's event system)
- Create a **local API endpoint** for mobile apps or other tools to connect to
...a native WebSocket server running on localhost is the solution.
### Dependencies (`Cargo.toml`)
```toml
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["net", "rt", "sync", "macros"] }
tokio-tungstenite = "0.24"
futures-util = "0.3"
```
**Key dependencies explained:**
- `tokio` — The async runtime Tauri uses internally. The `net` feature provides `TcpListener`, `sync` provides `broadcast` channels, `rt` provides the runtime, and `macros` provides `tokio::select!`.
- `tokio-tungstenite` — Industry-standard WebSocket implementation for Tokio. Provides `accept_async` for accepting WS connections and the `Message` type for frame construction.
- `futures-util` — Provides `StreamExt` (for `.next()` on streams) and `SinkExt` (for `.send()` on sinks).
### Server Implementation (`src-tauri/src/server.rs`)
```rust
use tokio::net::TcpListener;
use tokio_tungstenite::accept_async;
use futures_util::stream::StreamExt;
use futures_util::sink::SinkExt;
use tokio::sync::broadcast;
pub async fn start_websocket_server() {
// Bind to localhost only (security: not exposed to network)
let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap();
println!("WebSocket Server listening on ws://127.0.0.1:8080");
// Broadcast channel: fans out messages to ALL connected clients
// Queue depth of 16 means if a slow client hasn't read 16 messages,
// the oldest undelivered message is dropped (lagging policy)
let (tx, _rx) = broadcast::channel::<String>(16);
// Accept connections in a loop
while let Ok((stream, _addr)) = listener.accept().await {
// Clone the broadcast transmitter for this connection's task
let tx = tx.clone();
// Create a dedicated receiver for this connection
let mut rx = tx.subscribe();
// Spawn a separate async task per client connection
tauri::async_runtime::spawn(async move {
// Perform the WebSocket handshake
if let Ok(ws_stream) = accept_async(stream).await {
println!("New WebSocket client connected");
let (mut ws_sender, mut ws_receiver) = ws_stream.split();
// Main message loop using tokio::select!
loop {
tokio::select! {
// Branch A: Message FROM this specific client
incoming = ws_receiver.next() => {
match incoming {
Some(Ok(msg)) if msg.is_text() => {
let text = msg.to_text().unwrap().to_string();
println!("Received from client: {}", text);
// Broadcast to ALL other connected clients
let _ = tx.send(text);
}
Some(Ok(msg)) if msg.is_binary() => {
// Handle binary data if needed
let _ = tx.send(format!("[binary:{}bytes]", msg.len()));
}
_ => {
// None = client disconnected, Err = error
println!("Client disconnected");
break;
}
}
}
// Branch B: Broadcast message FROM another client
broadcast_msg = rx.recv() => {
if let Ok(payload) = broadcast_msg {
// Construct a WebSocket text frame and send it
let frame = tokio_tungstenite::tungstenite::Message::Text(
payload.into()
);
if let Err(e) = ws_sender.send(frame).await {
eprintln!("Failed to send to client: {}", e);
break;
}
}
}
}
}
} else {
eprintln!("WebSocket handshake failed");
}
});
}
}
```
### How the Broadcast Pattern Works
```text
Client A sends "hello" ─────▶ Rust Server ─────▶ broadcast("hello") ─────▶ Client B receives "hello"
─────▶ Client C receives "hello"
─────▶ Client D receives "hello"
Client B sends "world" ─────▶ Rust Server ─────▶ broadcast("world") ─────▶ Client A receives "world"
─────▶ Client C receives "world"
─────▶ Client D receives "world"
```
Each client:
1. Gets its own `rx = tx.subscribe()` receiver
2. The main loop uses `tokio::select!` to wait on BOTH incoming messages AND broadcast messages simultaneously
3. When a client sends a message, it goes through `tx.send()` which broadcasts to all OTHER clients (the sender's own receiver won't get its own message because `subscribe()` only receives messages sent after subscription)
### Spawning the Server from `lib.rs`
```rust
// src-tauri/src/lib.rs
mod server;
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.setup(|_app| {
// Spawn the WebSocket server as a background task
// This runs on Tauri's managed Tokio runtime
tauri::async_runtime::spawn(server::start_websocket_server());
Ok(())
})
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
```
**Key details:**
- `tauri::async_runtime::spawn()` is used instead of `tokio::spawn()` to ensure the task runs on Tauri's managed async runtime
- The server task runs for the entire lifetime of the application
- The `.setup()` closure returns immediately — the server runs in the background
---
## Part 2: Frontend WebSocket Client
### Main Window Connection (`src/main.js`)
The main application window connects to the WebSocket server using the standard browser `WebSocket` API:
```javascript
const { invoke } = window.__TAURI__.core;
// Connect to the local WebSocket server
const mainSocket = new WebSocket('ws://127.0.0.1:8080');
mainSocket.onopen = () => {
console.log('Main Control Window connected to the broadcast hub!');
};
mainSocket.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Received broadcast:', data);
// Handle specific message types
if (data.localsession) {
document.querySelector('#session-display').textContent =
`Session: ${data.localsession}`;
}
};
mainSocket.onerror = (err) => {
console.error('WebSocket connection error:', err);
};
// Sending data to other connected windows
function sendToAllWindows(payload) {
if (mainSocket.readyState === WebSocket.OPEN) {
mainSocket.send(JSON.stringify(payload));
} else {
console.warn('WebSocket not open — cannot send. readyState:', mainSocket.readyState);
}
}
// Example: button click sends data
document.querySelector('#broadcast-btn').addEventListener('click', () => {
sendToAllWindows({
sender: 'main-window',
type: 'notification',
content: 'Hello from the main window!'
});
});
```
### Message Protocol
While you can send any format over WebSocket, using JSON with a consistent structure is recommended:
```javascript
// Standard message envelope
{
"sender": "main-window", // Origin identifier
"type": "action-type", // Message category
"content": "payload data", // Message body
"timestamp": 1234567890 // Optional: for ordering
}
```
### Connection Lifecycle Management
For production apps, handle reconnection and cleanup:
```javascript
class TauriSocket {
constructor(url) {
this.url = url;
this.ws = null;
this.reconnectDelay = 1000;
this.maxReconnectDelay = 30000;
this.listeners = new Map();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected to local WS server');
this.reconnectDelay = 1000; // Reset on successful connect
this.emit('connected');
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.emit('message', data);
} catch (e) {
console.error('Failed to parse WS message:', e);
}
};
this.ws.onclose = () => {
console.log('Disconnected, reconnecting...');
this.emit('disconnected');
setTimeout(() => this.connect(), this.reconnectDelay);
this.reconnectDelay = Math.min(
this.reconnectDelay * 1.5,
this.maxReconnectDelay
);
};
this.ws.onerror = (err) => {
console.error('WebSocket error:', err);
};
}
send(data) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
on(event, callback) {
this.listeners.set(event, callback);
}
emit(event, data) {
this.listeners.get(event)?.(data);
}
disconnect() {
this.ws?.close();
}
}
// Usage
const socket = new TauriSocket('ws://127.0.0.1:8080');
socket.connect();
socket.on('message', (data) => console.log('Got:', data));
```
---
## Part 3: Script Injection into Webview Windows
### The Use Case
When your Tauri app opens windows with **external URLs** (e.g., a web-based admin panel, a third-party dashboard, or any remote page), those pages have no knowledge of Tauri or your local WebSocket server. Script injection allows you to:
1. **Bridge the external page** into your local communication network
2. **Extract data** from the page (localStorage, DOM, cookies) and relay it to Tauri
3. **Modify the page** (UI changes, injected controls, overlays)
4. **Share sessions** between windows (e.g., pass auth tokens)
### Injection via `initialization_script`
The `WebviewWindowBuilder::initialization_script()` method injects JavaScript that runs when the webview's page loads. Combined with `include_str!()`, you can load scripts from external files at compile time:
```rust
// src-tauri/src/lib.rs
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
#[tauri::command]
async fn open_remote_session(app_handle: AppHandle, url: String) -> Result<String, String> {
let target_url = url.parse()
.map(WebviewUrl::External)
.map_err(|_| format!("Invalid URL: {}", url))?;
let window_label = "remote-session";
// Check if window already exists — focus it instead of duplicating
if let Some(existing) = app_handle.get_webview_window(window_label) {
let _ = existing.set_focus();
return Ok(format!("Focused existing window for {}", url));
}
// Load the injection script at compile time
let script_to_inject = include_str!("../extWebview.js");
let app_clone = app_handle.clone();
let url_clone = target_url.clone();
// Window creation MUST happen on the main thread
app_handle.run_on_main_thread(move || {
let _window = WebviewWindowBuilder::new(&app_clone, window_label, url_clone)
.title("Remote Session")
.inner_size(1024.0, 768.0)
.resizable(true)
.focused(true)
.initialization_script(script_to_inject) // ← Inject JS here
.build();
}).map_err(|e| format!("Failed to dispatch to main thread: {}", e))?;
Ok(format!("Dispatched window spawn for: {}", url))
}
```
### The Injected Script (`src-tauri/extWebview.js`)
```javascript
// extWebview.js — This script runs automatically when the webview loads
window.addEventListener('DOMContentLoaded', () => {
console.log('[TAURI] Initializing injected socket link...');
const ws = new WebSocket('ws://127.0.0.1:8080');
ws.onopen = () => {
console.log('[TAURI] Injected socket connected to backend!');
// Announce this window's identity to the Tauri backend
const payload = {
sender: 'remote-webview',
type: 'identify',
url: window.location.href,
title: document.title
};
ws.send(JSON.stringify(payload));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('[TAURI] RECEIVED FROM BACKEND:', data);
// Respond with session/token data if requested
if (data.type === 'request-session') {
ws.send(JSON.stringify({
sender: 'remote-webview',
type: 'session-response',
localsession: window.localStorage.getItem('auth-token') || 'no-session'
}));
}
// Handle commands from the backend
if (data.type === 'execute-action') {
// Perform actions on the remote page as instructed by Tauri
console.log('[TAURI] Executing action:', data.action);
}
};
ws.onerror = (err) => {
console.error('[TAURI] Injected socket error:', err);
};
// Expose the socket on window for the page's own code to use
window.localAppSocket = ws;
// Periodically send status updates
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
sender: 'remote-webview',
type: 'heartbeat',
url: window.location.href
}));
}
}, 30000);
});
```
### CSP Considerations
Content Security Policy (CSP) can block script injection on external pages. If you need injection on external URLs:
```json
// tauri.conf.json — disable CSP (use with caution!)
{
"app": {
"security": {
"csp": null
}
}
}
```
For better security, craft a specific CSP that allows your injection patterns while blocking other sources:
```json
{
"app": {
"security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src ws://127.0.0.1:* http://127.0.0.1:* https:;"
}
}
}
```
---
## Complete Working Example Flow
### Step-by-step data flow through the entire system:
```text
1. App starts → lib.rs setup() → spawns WebSocket server on ws://127.0.0.1:8080
2. Main window (index.html) loads → main.js creates WebSocket client
→ connects to ws://127.0.0.1:8080
→ onopen fires → ready to communicate
3. User enters URL and clicks button → main.js calls invoke('greet', { name: url })
4. Rust greet() command:
→ parses URL as WebviewUrl::External
→ checks if window already exists (focus if so)
→ loads extWebview.js via include_str!()
→ dispatches window creation to main thread
→ creates new WebviewWindow with external URL + injected script
5. Remote window loads external page → extWebview.js runs on DOMContentLoaded
→ creates WebSocket client
→ connects to ws://127.0.0.1:8080
→ sends identification message
6. Server broadcasts identification to all clients
→ Main window receives it → displays session info
→ Other remote windows receive it (if any)
7. Main window user clicks "Send" → main.js sends JSON via WebSocket
→ Server broadcasts to all clients
→ All remote windows receive and log the message
```
---
## Choosing Between Approaches
| Requirement | Recommended Approach |
|-------------|---------------------|
| Connect to external WebSocket server | `tauri-plugin-websocket` (client plugin) |
| App IS the WebSocket server | Native Tokio server (this guide) |
| Tauri-to-frontend communication | Tauri events (`emit`/`listen`) |
| High-throughput streaming data | Tauri channels (`Channel<T>`) |
| Cross-window messaging (internal) | Tauri events or native WS server |
| External page needs to talk to Tauri | Native WS server + script injection |
| Multiple external pages sharing state | Native WS server + script injection |
---
## Troubleshooting
### "Address already in use" (port 8080)
- Another process is using port 8080
- Solution: Change the port in both `server.rs` and all frontend connection code
- Or kill the process using port 8080: `lsof -i :8080` / `netstat -ano | findstr :8080`
### WebSocket connection fails from injected scripts
- CSP may be blocking the connection → set `"csp": null` in config
- The injected script runs before the page's own scripts → ensure DOM is ready via `DOMContentLoaded`
- External HTTPS pages may refuse unencrypted WebSocket connections → page must allow mixed content
### Messages not broadcasting to other windows
- Each client needs its own `rx = tx.subscribe()` receiver
- The `tx.send()` call broadcasts to all subscribers EXCEPT the sender's own subscription
- Ensure `tokio::select!` has both branches (incoming AND broadcast)
### Window creation fails from async command
- Window creation MUST happen on the main thread
- Use `app_handle.run_on_main_thread(move || { ... })` for async contexts
- Not doing this will cause a panic or silent failure