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
559 lines
21 KiB
Markdown
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
|