Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# ObsidianIRC Architecture

> **Modern IRC Client** - React + TypeScript + TailwindCSS + Tauri
> Next-generation IRC client supporting websockets only
> Next-generation IRC client with WebSocket support in web builds and native TCP/TLS transport in Tauri builds

## 🏗️ Project Structure

Expand Down Expand Up @@ -75,15 +75,31 @@ interface AppState {
- Optimistic UI updates

### IRC Protocol Layer
**Location:** `src/lib/ircClient.ts`
**Location:** `src/lib/irc/IRCClient.ts`

Event-driven IRC client supporting:
- WebSocket-only connections (no raw TCP)
- WebSocket connections for browser-compatible paths
- Native TCP/TLS connections through the Tauri backend on desktop builds
- SASL authentication
- IRC v3 message tags
- Capability negotiation
- Multi-server management

### Transport Layer
**Locations:** `src/lib/socket.ts`, `src-tauri/src/socket.rs`

ObsidianIRC has a split transport model:

- `wss://` routes through `WebSocketWrapper` in the frontend.
- `irc://` and `ircs://` route through `TCPSocket` in the frontend.
- `TCPSocket` bridges to Tauri commands (`connect`, `listen`, `send`, `disconnect`) implemented in Rust.
- The Rust backend owns the real TCP/TLS socket lifecycle, reads and writes IRC lines, and emits `tcp-message` events back to the frontend.

This means transport hardening work usually belongs in two places:

- frontend lifecycle and state transitions in `IRCClient` / `socket.ts`
- native socket behavior and shutdown semantics in `src-tauri/src/socket.rs`

**Event System:**
```typescript
interface EventMap {
Expand Down
62 changes: 49 additions & 13 deletions src-tauri/src/socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ struct MessageData {
data: Vec<u8>,
}

async fn take_connection(
state: &Arc<Mutex<HashMap<String, ConnectionHandle>>>,
client_id: &str,
) -> Option<ConnectionHandle> {
let mut connections = state.lock().await;
connections.remove(client_id)
}

/// Read task for handling incoming data from the socket
async fn read_task<R>(
client_id: String,
Expand All @@ -66,6 +74,11 @@ async fn read_task<R>(
loop {
match reader.read(&mut read_buf).await {
Ok(0) => {
let was_tracked = take_connection(&state, &client_id).await.is_some();
if !was_tracked {
break;
}

// Connection closed by server
// Emit any remaining partial data as a final message
if !line_buffer.is_empty() {
Expand All @@ -87,10 +100,6 @@ async fn read_task<R>(
connected: Some(false),
},
});

// Remove connection from state
let mut connections = state.lock().await;
connections.remove(&client_id);
break;
}
Ok(n) => {
Expand Down Expand Up @@ -122,6 +131,11 @@ async fn read_task<R>(
}
}
Err(e) => {
let was_tracked = take_connection(&state, &client_id).await.is_some();
if !was_tracked {
break;
}

// Read error - emit error event and stop
let _ = app_handle.emit("tcp-message", ReceivedPayload {
id: client_id.clone(),
Expand All @@ -131,10 +145,6 @@ async fn read_task<R>(
connected: Some(false),
},
});

// Remove connection from state
let mut connections = state.lock().await;
connections.remove(&client_id);
break;
}
}
Expand Down Expand Up @@ -340,16 +350,14 @@ fn parse_host_port(host_port: &str, default_port: u16) -> Result<(String, u16),
/// Disconnect a specific client connection
#[tauri::command]
pub async fn disconnect(client_id: String, state: State<'_, SocketState>) -> Result<(), String> {
let mut connections = state.0.lock().await;
if let Some(mut handle) = connections.remove(&client_id) {
if let Some(mut handle) = take_connection(&state.0, &client_id).await {
// Send shutdown signal if available
if let Some(shutdown_tx) = handle.shutdown_tx.take() {
let _ = shutdown_tx.send(());
}
Ok(())
} else {
Err(format!("No connection found for client_id: {}", client_id))
}

Ok(())
}

/// Start listening for messages from all active connections
Expand Down Expand Up @@ -388,3 +396,31 @@ pub async fn send(
Err(format!("No connection found for client_id: {}", client_id))
}
}

#[cfg(test)]
mod tests {
use super::*;

fn make_connection_handle() -> ConnectionHandle {
let (write_tx, _write_rx) = mpsc::channel(1);
let (shutdown_tx, _shutdown_rx) = oneshot::channel();

ConnectionHandle {
write_tx,
shutdown_tx: Some(shutdown_tx),
}
}

#[tokio::test]
async fn take_connection_is_idempotent() {
let state = Arc::new(Mutex::new(HashMap::new()));

{
let mut connections = state.lock().await;
connections.insert("client-1".to_string(), make_connection_handle());
}

assert!(take_connection(&state, "client-1").await.is_some());
assert!(take_connection(&state, "client-1").await.is_none());
}
}
84 changes: 37 additions & 47 deletions src/lib/irc/IRCClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,8 @@ import type {
Server,
User,
} from "../../types";
import { parseIrcUrl } from "../ircUrlParser";
import { parseMessageTags } from "../ircUtils";
import { createSocket, type ISocket } from "../socket";
import { createSocket, type ISocket, resolveSocketTarget } from "../socket";
import { IRC_DISPATCH } from "./handlers";
import type { IRCClientContext } from "./IRCClientContext";

Expand Down Expand Up @@ -391,6 +390,7 @@ type EventCallback<K extends EventKey> = (data: EventMap[K]) => void;

export class IRCClient implements IRCClientContext {
private sockets: Map<string, ISocket> = new Map();
private intentionalDisconnects: Set<string> = new Set();
servers: Map<string, Server> = new Map();
nicks: Map<string, string> = new Map();
currentUsers: Map<string, User | null> = new Map(); // Per-server current users
Expand Down Expand Up @@ -491,6 +491,10 @@ export class IRCClient implements IRCClientContext {
): Promise<Server> {
const connectionKey = `${host}:${port}`;

if (serverId) {
this.intentionalDisconnects.delete(serverId);
}

// Check if there's already a pending connection to this server
const existingConnection = this.pendingConnections.get(connectionKey);
if (existingConnection) {
Expand All @@ -509,47 +513,10 @@ export class IRCClient implements IRCClientContext {

// Create a new connection promise and store it
const connectionPromise = new Promise<Server>((resolve, reject) => {
let protocol: "wss" | "ircs" | "irc" = "wss";
let actualHost = host;
let actualPort = port;
let actualPath = "";

if (host.startsWith("irc://") || host.startsWith("ircs://")) {
// Parse the IRC URL using centralized parser (Android-compatible)
const parsed = parseIrcUrl(host);

protocol = parsed.scheme;
actualHost = parsed.host;
actualPort = parsed.port;
} else if (host.startsWith("wss://")) {
// Use URL constructor to preserve path/query (e.g. wss://host/websocket?token=...)
try {
const parsed = new URL(host);
actualHost = parsed.hostname;
actualPort = parsed.port ? Number.parseInt(parsed.port, 10) : port;
actualPath =
parsed.pathname !== "/"
? parsed.pathname + parsed.search
: parsed.search;
} catch {
// malformed URL — leave actualHost/Port from the default
}
} else if (host.startsWith("ws://")) {
// Upgrade legacy ws:// to wss:// — unencrypted WebSockets are no longer supported
try {
const parsed = new URL(host);
actualHost = parsed.hostname;
actualPort = parsed.port ? Number.parseInt(parsed.port, 10) : port;
actualPath =
parsed.pathname !== "/"
? parsed.pathname + parsed.search
: parsed.search;
} catch {
// malformed URL — leave actualHost/Port from the default
}
}

const url = `${protocol}://${actualHost}:${actualPort}${actualPath}`;
const target = resolveSocketTarget(host, port);
const url = target.url;
const actualHost = target.host;
const actualPort = target.port;
const socket = createSocket(url);

// Create server object immediately and add to servers map
Expand Down Expand Up @@ -649,6 +616,14 @@ export class IRCClient implements IRCClientContext {
// to ensure connection is fully established before sending PINGs

socket.onclose = () => {
if (this.intentionalDisconnects.has(server.id)) {
this.intentionalDisconnects.delete(server.id);
this.stopWebSocketPing(server.id);
this.sockets.delete(server.id);
this.pendingConnections.delete(connectionKey);
return;
}

if (!this.servers.has(server.id)) {
return;
}
Expand Down Expand Up @@ -712,13 +687,27 @@ export class IRCClient implements IRCClientContext {

disconnect(serverId: string, quitMessage?: string): void {
const socket = this.sockets.get(serverId);
const server = this.servers.get(serverId);

if (socket) {
const message = quitMessage || "ObsidianIRC - Bringing IRC to the future";
socket.send(`QUIT :${message}`);
socket.close();
const CONNECTING = 0;
const OPEN = 1;

this.intentionalDisconnects.add(serverId);

if (socket.readyState === OPEN && server?.isConnected) {
const message =
quitMessage || "ObsidianIRC - Bringing IRC to the future";
socket.send(`QUIT :${message}`);
}

if (socket.readyState === CONNECTING || socket.readyState === OPEN) {
socket.close();
}

this.sockets.delete(serverId);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const server = this.servers.get(serverId);

if (server) {
server.isConnected = false;
server.connectionState = "disconnected";
Expand All @@ -742,6 +731,7 @@ export class IRCClient implements IRCClientContext {

removeServer(serverId: string): void {
this.disconnect(serverId);
this.intentionalDisconnects.delete(serverId);
this.servers.delete(serverId);
this.capNegotiationComplete.delete(serverId);
this.pendingCapReqs.delete(serverId);
Expand Down
Loading
Loading