Skip to content

IRC over TCP in the app shell - #9

Closed
rdrake wants to merge 6 commits into
developfrom
claude/project-thread-i6m8k5
Closed

rdrake wants to merge 6 commits into
developfrom
claude/project-thread-i6m8k5

Conversation

@rdrake

@rdrake rdrake commented Nov 8, 2026 •

Copy link
Copy Markdown
Owner

What

The app (Tauri shell) can now connect to IRC networks over plain TCP or TLS, not just WebSocket, so users can join networks that have no WebSocket listener. In the connect form and the network editor, a new Connection choice (WebSocket / TCP) appears only inside the app. In a browser or the PWA nothing changes. Browsers have no raw-socket API outside managed ChromeOS, so they would need a gateway, which is out of scope here.

How

  • client/js/irc/shellSocket.ts: ShellSocket dresses a shell-made TCP socket as the part of a WebSocket that WsTransport uses (the new LineSocket interface). Retries, the probe, PING, the line cap and registration are shared unchanged.
  • shells/tauri/src-tauri/src/socket.rs: a Socket plugin built on tokio and rustls (ring, Mozilla roots). It splits and decodes lines (UTF-8, Latin-1 fallback) and emits them in batches.
  • Saved networks and STS carry transport: "tcp". STS policies are keyed tcp:host so a WebSocket policy never rewrites a TCP dial. Default ports are 6667/6697. The disconnect hints have TCP-specific wording.

Limits on the raw socket

The page can be made to dial anything, so the shell restricts what it will do:

  • refuses the Fetch bad-ports list, minus the IRC ports;
  • refuses CR, LF, NUL and empty lines;
  • sends only registration commands until the server's first line looks like IRC, which stops HTTP/SMTP smuggling;
  • a 512-line send queue, a 10 s write timeout, a 2 s drain on close, and at most 16 sockets;
  • a per-load page token, so a reload can't adopt the old page's sockets;
  • a 1 s grace on reload so the pagehide QUIT still goes out.

A failure a retry can't mend (an untrusted certificate, a server that isn't IRC) closes with code 4001, and the page shows the reason once instead of retrying. build.rs restricts the app's commands to seance_call, which capabilities/default.json grants to the main window only.

Testing

  • pnpm lint, pnpm typecheck, pnpm i18n:check and mocha all pass (new test/irc/shellSocket.ts, plus TCP cases in the client, saved-networks, STS and disconnect tests). cargo test passes (12 tests) and clippy is clean.
  • In the macOS app:
    • plain TCP to the dev ircd: registers, joins and sends a message;
    • disconnect, connect and quit work;
    • a reload sends the QUIT and leaves no orphan;
    • TLS to a self-signed ircd fails with a clear reason;
    • TLS to irc.libera.chat registers;
    • switching a saved TCP network back to WebSocket dials ws:// and drops transport from the saved entry.

Known limits

  • A WebSocket STS policy doesn't upgrade a first plain-TCP dial.
  • Android and iOS are untested.

Networks with no WebSocket listener can be reached from the Tauri apps
over plain TCP or TLS. The page keeps WsTransport and its whole
connection policy; underneath it, ShellSocket stands in for the
WebSocket and moves lines through a new Socket plugin in the shell
(src/socket.rs), which does the framing, TLS (rustls on ring, Mozilla's
roots) and the limits a page-reachable raw socket needs.

- transport.ts: WebSocketImpl takes a LineSocket, the part of WebSocket
  WsTransport uses.
- shellSocket.ts: ordered shell calls per socket, its own close event,
  error-then-close on failure.
- ConnectOptions.transport ("tcp"), carried through saved networks,
  applySettings and endpointChanged; TCP default ports 6667/6697.
- STS policies keyed per transport (tcp:host), so a WebSocket port is
  never used for TCP or the reverse.
- TCP-specific close hints; a Connection select in both network forms,
  shown only inside the app.
- A browser refuses a TCP network instead of retrying it.

No certificate pinning yet: a self-signed server is reachable over
plain TCP only.
Shell socket (src/socket.rs):
- Keep it an IRC client: no empty lines, and only registration commands
  until the server's first line looks like IRC; a first line that does
  not (an HTTP status, a Redis -ERR, an SMTP greeting) closes the socket.
- A write that takes over 10 s closes the socket; after the page closes
  one, the queued lines, the flush and the shutdown share the 2 s drain.
- Sends are refused until the socket is open.
- Every open carries the page load's token; an open from the page before
  a reload, handled after it, is refused instead of leaving an orphan.
- A page load keeps the old sockets for 1 s so the QUIT its pagehide
  sends still goes out (WebKit starts the new load before pagehide).
- An untrusted certificate is reported in words; the TLS config is built
  once.

Page:
- The connect form can switch a saved TCP network back to WebSocket
  (saved-networks.ts mergeOptions), and findMatching keys on transport.
- ShellSocket: a bridge that throws at once fails like a refusal, and a
  listener's throw no longer costs the rest of a read's lines.
- Feature notes in docs/resources/client-features.md.
- The first-line gate wants a prefix or tags, or PING/NOTICE/ERROR with a
  parameter: memcached answers anything with a bare ERROR.
- At most 30 opens a minute, so the page cannot scan a LAN through the shell.
- A certificate not valid yet (a wrong device clock) is retried, not final.
- The forms send transport "websocket" to switch back; options without a
  transport keep the stored one, and comparisons treat websocket as absent.
- One end_socket for both ways a socket ends; helpers private; no separate
  tokio dev-dependency.

This branch was previously deployed

1 inactive deployment
github-pages — 0387f3c5 Deployed Nov 8, 2026 by rdrake via deploy #179
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants