From 425e0788728d17d49f109ef851d3a8f6b7da7f77 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 15:46:37 +0000 Subject: [PATCH 1/6] Prototype IRC over TCP in the app shell 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. --- client/components/NetworkForm.vue | 44 ++- client/components/Windows/Connect.vue | 48 ++- client/js/i18n/call-sites.ts | 6 + client/js/irc/client.ts | 69 +++- client/js/irc/disconnect.ts | 10 + client/js/irc/manager.ts | 3 + client/js/irc/saved-networks.ts | 22 +- client/js/irc/shellSocket.ts | 237 +++++++++++++ client/js/irc/sts.ts | 12 +- client/js/irc/transport.ts | 39 +- client/js/irc/types.ts | 5 + client/locales/en.json | 6 + client/locales/messages.pot | 37 ++ client/locales/qqx.json | 6 + shells/tauri/src-tauri/Cargo.lock | 191 +++++++++- shells/tauri/src-tauri/Cargo.toml | 9 + shells/tauri/src-tauri/src/lib.rs | 12 +- shells/tauri/src-tauri/src/socket.rs | 493 ++++++++++++++++++++++++++ test/irc/client.ts | 59 ++- test/irc/disconnect.ts | 25 ++ test/irc/saved-networks.ts | 31 ++ test/irc/shellSocket.ts | 279 +++++++++++++++ test/irc/sts.ts | 20 ++ 23 files changed, 1627 insertions(+), 36 deletions(-) create mode 100644 client/js/irc/shellSocket.ts create mode 100644 shells/tauri/src-tauri/src/socket.rs create mode 100644 test/irc/shellSocket.ts diff --git a/client/components/NetworkForm.vue b/client/components/NetworkForm.vue index d1481cbe2..853947ad8 100644 --- a/client/components/NetworkForm.vue +++ b/client/components/NetworkForm.vue @@ -81,6 +81,20 @@ /> +
+ +
+ +
+
@@ -486,6 +500,7 @@ import { watch, } from "vue"; import { + defaultPort, displayName, parseCommands, presenceModeOf, @@ -499,7 +514,7 @@ import {describeServiceCheck} from "../js/irc/services"; import {servicesCheck} from "../js/helpers/operRequest"; import {isOperOn} from "../js/helpers/operMenu"; import {askNativeAlertPermission, nativeAlertsAvailable} from "../js/helpers/nativeAlerts"; -import {isDesktopShell} from "../js/helpers/shell"; +import {isDesktopShell, isShell} from "../js/helpers/shell"; import {useStore} from "../js/store"; import webpush from "../js/webpush"; import {useI18n} from "../js/i18n"; @@ -806,6 +821,14 @@ export default defineComponent({ watch( () => props.defaults.tls, (isSecureChecked) => { + if (props.defaults.transport === "tcp") { + if (props.defaults.port === defaultPort(!isSecureChecked, "tcp")) { + props.defaults.port = defaultPort(isSecureChecked, "tcp"); + } + + return; + } + const ports = [8067, 8443]; const newPort = isSecureChecked ? 1 : 0; @@ -815,6 +838,23 @@ export default defineComponent({ } ); + // IRC over TCP: only the app shell can open a raw socket. + const canUseTcp = isShell(); + const connection = computed({ + get: () => (props.defaults.transport === "tcp" ? "tcp" : "websocket"), + set(value: string) { + const tcp = value === "tcp"; + const tls = props.defaults.tls; + const from = tcp ? (tls ? 8443 : 8067) : defaultPort(tls, "tcp"); + + if (props.defaults.port === from) { + props.defaults.port = tcp ? defaultPort(tls, "tcp") : tls ? 8443 : 8067; + } + + props.defaults.transport = tcp ? "tcp" : undefined; + }, + }); + const formData = (): SavedNetwork => { const data: SavedNetwork = { ...props.defaults, @@ -887,6 +927,8 @@ export default defineComponent({ presenceHint, pushInfo, desktopShell: isDesktopShell(), + canUseTcp, + connection, renewPush, onNotifyToggle, SERVICES_PROFILES, diff --git a/client/components/Windows/Connect.vue b/client/components/Windows/Connect.vue index 8a6f059f6..ee0e2d502 100644 --- a/client/components/Windows/Connect.vue +++ b/client/components/Windows/Connect.vue @@ -157,6 +157,20 @@ />
+
+ +
+ +
+
@@ -413,6 +427,7 @@ import {brandingFeatures, expandNick, nickFromAccount} from "../../js/branding"; import {autoconnectSavedNetworks, createNetwork} from "../../js/irc/manager"; import * as saved from "../../js/irc/saved-networks"; import {defaultPort, SavedNetwork} from "../../js/irc/saved-networks"; +import {isShell} from "../../js/helpers/shell"; import {mergeJoinLists} from "../../js/helpers/linkTarget"; import {askNativeAlertPermission, nativeAlertsAvailable} from "../../js/helpers/nativeAlerts"; import {router, switchToChannel} from "../../js/router"; @@ -486,9 +501,29 @@ export default defineComponent({ const pinServer = () => { if (hostLocked) { Object.assign(form, server); + delete form.transport; // the deploy's server is a WebSocket one } }; + // IRC over TCP: only the app shell can open a raw socket. + const canUseTcp = isShell(); + const connection = computed({ + get: () => form.transport ?? "websocket", + set(value: string) { + const next = value === "tcp" ? "tcp" : undefined; + + if (form.port === defaultPort(form.tls, form.transport)) { + form.port = defaultPort(form.tls, next); + } + + if (next) { + form.transport = next; + } else { + delete form.transport; + } + }, + }); + // The sign-in panel replaces the whole form; `brandingFeatures` only // reports it when there is a `defaultNetwork` to sign in to, and pins // the server when it does. @@ -540,6 +575,13 @@ export default defineComponent({ form.host = net.host; form.port = net.port; form.tls = net.tls; + + if (net.transport) { + form.transport = net.transport; + } else { + delete form.transport; + } + form.nick = net.nick; form.join = net.join; form.sasl = net.sasl; @@ -636,8 +678,8 @@ export default defineComponent({ watch( () => form.tls, (useTls) => { - if (form.port === defaultPort(!useTls)) { - form.port = defaultPort(useTls); + if (form.port === defaultPort(!useTls, form.transport)) { + form.port = defaultPort(useTls, form.transport); } } ); @@ -750,6 +792,8 @@ export default defineComponent({ form, store, t, + canUseTcp, + connection, ariaLabel, serverAddressLabel, serverPortLabel, diff --git a/client/js/i18n/call-sites.ts b/client/js/i18n/call-sites.ts index a9b279f50..a46f84c9c 100644 --- a/client/js/i18n/call-sites.ts +++ b/client/js/i18n/call-sites.ts @@ -166,6 +166,9 @@ export const STATIC_CALL_SITES = new Set([ "connect.connectingAs", "connect.connectingTo", "connect.connectingToAttempt", + "connect.connection", + "connect.connection.tcp", + "connect.connection.websocket", "connect.disconnected", "connect.enabledCaps", "connect.enterPassword", @@ -212,6 +215,7 @@ export const STATIC_CALL_SITES = new Set([ "connect.stsReconnect", "connect.stsUpgrade", "connect.submit", + "connect.tcpNeedsApp", "connect.title", "connect.useTls", "connect.user", @@ -238,6 +242,8 @@ export const STATIC_CALL_SITES = new Set([ "disconnect.hint.plainCauses", "disconnect.hint.registration", "disconnect.hint.sessionConflict", + "disconnect.hint.tcpPlainCauses", + "disconnect.hint.tcpTlsCauses", "disconnect.hint.wssCauses", "disconnect.registeringClosed", "disconnect.registeringClosedNotReconnecting", diff --git a/client/js/irc/client.ts b/client/js/irc/client.ts index c28ce3589..88147b83c 100644 --- a/client/js/irc/client.ts +++ b/client/js/irc/client.ts @@ -143,8 +143,10 @@ import { policyHost, refreshPolicy, StsUpgrade, + stsHost, upgradeOptions, } from "./sts"; +import {shellSocketCtor, type SocketBridge} from "./shellSocket"; import {channelOrder, get as getSavedNetwork, NetworkCursor, setCursor} from "./saved-networks"; import {noteServiceReply, resetServiceReplies} from "./services"; import {dropQuietLine, isRepeatedMissingBot} from "./quiet"; @@ -268,6 +270,12 @@ export interface IrcClientOptions extends ConnectOptions { ids?: IdAllocator; /** Build the transport (tests inject a fake); defaults to `new WsTransport(opts)`. */ transportFactory?: (opts: TransportOptions) => Transport; + /** + * The app shell's bridge, for `transport: "tcp"` (shellSocket.ts). The + * manager passes `window.seanceShell`; without one (a browser) a TCP + * network does not connect. + */ + socketBridge?: SocketBridge; /** The server's flood rule as we model it (fakelag.ts); ircu's by default. */ fakeLag?: FakeLagModel; reconnect?: ReconnectOptions; @@ -582,7 +590,7 @@ export class IrcClient { this.networkName = hostnameOf(host); this.bus = options.bus ?? socket; this.ids = options.ids ?? sharedIds; - this.url = buildUrl(host, options.port, options.tls); + this.url = buildUrl(host, options.port, options.tls, options.transport); this.transport = this.createTransport(); this.unsubscribeTransport = this.transport.on((ev) => this.onTransportEvent(ev)); @@ -814,7 +822,8 @@ export class IrcClient { proxyPort: 0, proxyUsername: "", proxyPassword: "", - hasSTSPolicy: getPolicy(o.host) !== undefined, + transport: o.transport ?? "websocket", + hasSTSPolicy: getPolicy(stsHost(o)) !== undefined, }; } @@ -835,6 +844,17 @@ export class IrcClient { this.settleClose(); // before quitting is reset: it was our QUIT this.applyPendingOptions(); + + if (this.options.transport === "tcp" && !this.options.socketBridge) { + // No app shell, no raw socket: say so rather than dial and retry forever. + this.pushMessage( + this.lobby, + {type: MessageType.ERROR, text: t("connect.tcpNeedsApp")}, + true + ); + return; + } + this.applyStsPolicy(); this.quitting = false; this.closeHintShown = false; @@ -907,6 +927,7 @@ export class IrcClient { host: next.host, port: next.port, tls: next.tls, + transport: next.transport, join: next.join, sasl: next.sasl, saslAccount: next.saslAccount, @@ -945,7 +966,12 @@ export class IrcClient { private endpointChanged(next: IrcClientOptions): boolean { const o = this.options; - return next.host !== o.host || next.port !== o.port || next.tls !== o.tls; + return ( + next.host !== o.host || + next.port !== o.port || + next.tls !== o.tls || + next.transport !== o.transport + ); } /** Swap in settings edited while connected, if any (before a connect). */ @@ -986,6 +1012,14 @@ export class IrcClient { subprotocols: ["text.ircv3.net", "binary.ircv3.net"], }; + if (this.options.transport === "tcp" && this.options.socketBridge) { + transportOptions.WebSocketImpl = shellSocketCtor(this.options.socketBridge, { + host: tcpHostOf(this.options.host), + port: this.options.port, + tls: this.options.tls, + }); + } + if (this.options.reconnect) { transportOptions.reconnect = this.options.reconnect; } @@ -1000,7 +1034,7 @@ export class IrcClient { this.settleClose(); // the old transport's close event will not reach us this.unsubscribeTransport(); this.options = {...next}; - this.url = buildUrl(next.host, next.port, next.tls); + this.url = buildUrl(next.host, next.port, next.tls, next.transport); this.transport = this.createTransport(); this.unsubscribeTransport = this.transport.on((ev) => this.onTransportEvent(ev)); } @@ -1052,7 +1086,7 @@ export class IrcClient { const value = parseStsValue(raw); if (this.options.tls) { - applyDuration(this.options.host, this.options.port, value); + applyDuration(stsHost(this.options), this.options.port, value); return; } @@ -1068,7 +1102,11 @@ export class IrcClient { // secure endpoint; an edit to another host is left alone. const pending = this.pendingOptions; - if (pending && policyHost(pending.host) === policyHost(this.options.host)) { + if ( + pending && + pending.transport === this.options.transport && + policyHost(pending.host) === policyHost(this.options.host) + ) { this.pendingOptions = {...pending, ...secure}; } @@ -1628,7 +1666,7 @@ export class IrcClient { this.authtoken.clear(); if (this.options.tls) { - refreshPolicy(this.options.host); + refreshPolicy(stsHost(this.options)); } resetBatches(this); @@ -3646,7 +3684,12 @@ function hostnameOf(host: string): string { return slash === -1 ? stripped : stripped.slice(0, slash); } -export function buildUrl(host: string, port: number, tls: boolean): string { +export function buildUrl( + host: string, + port: number, + tls: boolean, + transport?: ConnectOptions["transport"] +): string { const stripped = host.trim().replace(/^(?:wss?|https?|ircs?):\/\//i, ""); const slash = stripped.indexOf("/"); let hostname = slash === -1 ? stripped : stripped.slice(0, slash); @@ -3656,9 +3699,19 @@ export function buildUrl(host: string, port: number, tls: boolean): string { hostname = `[${hostname}]`; // bare IPv6 literal } + if (transport === "tcp") { + return `${tls ? "ircs" : "irc"}://${hostname}:${port}`; // no path over TCP + } + return `${tls ? "wss" : "ws"}://${hostname}:${port}${path}`; } +/** The host to dial over TCP: no scheme, path or IPv6 brackets. */ +export function tcpHostOf(host: string): string { + const name = hostnameOf(host.trim()); + return name.startsWith("[") && name.endsWith("]") ? name.slice(1, -1) : name; +} + /** The cursor stored for `uuid`, if the network was saved and has one. */ function savedCursor(uuid: string): NetworkCursor | undefined { try { diff --git a/client/js/irc/disconnect.ts b/client/js/irc/disconnect.ts index 997107d7c..0a7888a74 100644 --- a/client/js/irc/disconnect.ts +++ b/client/js/irc/disconnect.ts @@ -83,6 +83,16 @@ export function describeClose(ctx: CloseContext): CloseReport { const text = retrying ? t("disconnect.couldNotConnect", vars) : t("disconnect.couldNotConnectNotReconnecting", vars); + // IRC over TCP (the app shell): no browser rules apply, and an + // untrusted certificate is not the user's to accept in a tab. + if (ctx.url.startsWith("ircs:")) { + return {text, hint: t("disconnect.hint.tcpTlsCauses")}; + } + + if (ctx.url.startsWith("irc:")) { + return {text, hint: t("disconnect.hint.tcpPlainCauses")}; + } + const secure = ctx.url.startsWith("wss:"); if (!secure && ctx.pageProtocol === "https:") { diff --git a/client/js/irc/manager.ts b/client/js/irc/manager.ts index 6bc07a094..3edd78a47 100644 --- a/client/js/irc/manager.ts +++ b/client/js/irc/manager.ts @@ -30,6 +30,7 @@ import * as saved from "./saved-networks"; import type {SavedNetwork} from "./saved-networks"; import type {ConnectOptions} from "./types"; import type {GlobalModeState} from "../helpers/dnd"; +import {shellBridge} from "../helpers/shell"; /** The global do-not-disturb as client/js/dnd.ts last set it; new clients start with it. */ const globalDnd: {active: boolean; until: number | null} = {active: false, until: null}; @@ -110,6 +111,8 @@ export function createNetwork(options: CreateNetworkOptions): IrcClient { client = new IrcClient({ ...saved.toConnectOptions(entry), uuid: entry.uuid, + // The app shell's raw sockets, for a network set to IRC over TCP. + socketBridge: shellBridge() ?? undefined, highlights: highlightKeywords, networksForInit: allNetworks, setMuteStatus, diff --git a/client/js/irc/saved-networks.ts b/client/js/irc/saved-networks.ts index 1bd6f80a5..45b28f626 100644 --- a/client/js/irc/saved-networks.ts +++ b/client/js/irc/saved-networks.ts @@ -97,7 +97,11 @@ export function useStorageBackend(next: StorageBackend | null): void { } /** The WebSocket ports we suggest: 9998 for wss://, 8067 for ws://. */ -export function defaultPort(tls: boolean): number { +export function defaultPort(tls: boolean, transport?: ConnectOptions["transport"]): number { + if (transport === "tcp") { + return tls ? 6697 : 6667; + } + return tls ? 9998 : 8067; } @@ -163,9 +167,9 @@ function asBoolean(value: unknown): boolean { return s !== "" && s !== "0" && s !== "false" && s !== "off"; } -function asPort(value: unknown, tls: boolean): number { +function asPort(value: unknown, tls: boolean, transport?: ConnectOptions["transport"]): number { const port = typeof value === "number" ? value : parseInt(asString(value), 10); - return Number.isInteger(port) && port > 0 && port <= 65535 ? port : defaultPort(tls); + return Number.isInteger(port) && port > 0 && port <= 65535 ? port : defaultPort(tls, transport); } /** A stored / incoming cursor, or undefined when there is nothing usable in it. */ @@ -248,12 +252,15 @@ export function normalize(raw: Record): SavedNetwork | undefine const tls = raw.tls === undefined ? true : asBoolean(raw.tls); const sasl = asString(raw.sasl) === "plain" ? "plain" : ""; const rememberPassword = asBoolean(raw.rememberPassword); + // Only `tcp` is stored; anything else (the form's `websocket`) is the default. + const transport = raw.transport === "tcp" ? "tcp" : undefined; const net: SavedNetwork = { uuid, name: asString(raw.name), host: asString(raw.host), - port: asPort(raw.port, tls), + port: asPort(raw.port, tls, transport), tls, + ...(transport ? {transport} : {}), nick: asString(raw.nick), join: asJoin(raw.join), sasl, @@ -366,6 +373,12 @@ export function fromForm(data: Record, existing?: SavedNetwork) } } + // The connection select sends `websocket` (or a cleared value) to go + // back from TCP: the loop above would keep the stored `tcp` for it. + if ("transport" in data && data.transport !== "tcp") { + delete base.transport; + } + // A blank password field in the edit form means "keep what I had". if (existing && asString(data.saslPassword) === "" && existing.saslPassword) { base.saslPassword = existing.saslPassword; @@ -392,6 +405,7 @@ export function toConnectOptions(net: SavedNetwork): ConnectOptions { host: net.host, port: net.port, tls: net.tls, + ...(net.transport ? {transport: net.transport} : {}), nick: net.nick, join: net.join, sasl: net.sasl, diff --git a/client/js/irc/shellSocket.ts b/client/js/irc/shellSocket.ts new file mode 100644 index 000000000..fd924b027 --- /dev/null +++ b/client/js/irc/shellSocket.ts @@ -0,0 +1,237 @@ +/** + * ShellSocket — a raw TCP/TLS IRC connection made by the app shell + * (shells/tauri/src-tauri/src/socket.rs), dressed as the part of a WebSocket + * that {@link WsTransport} uses ({@link LineSocket}). IRC over TCP carries the + * same lines as IRC over WebSocket, so everything above the socket — the + * retry schedule, the probe, PING, the line cap — is shared unchanged. + * + * The shell is reached through a {@link SocketBridge} (`window.seanceShell` + * in the app), passed in so this module stays DOM-free and runs under mocha. + * + * The contract WsTransport relies on: + * - every event is asynchronous; `open` comes after `readyState` is OPEN; + * - calls to the shell run in order per socket (open, sends, close): each + * bridge call is its own IPC request and two may otherwise overtake each + * other, which would reorder a pipelined registration or QUIT-then-close; + * - a failure fires `error` (carrying the shell's reason) then one `close`; + * - `close()` from the page fires its own `close` event, once: the shell + * says nothing about a socket the page closed. + */ + +import type {LineSocket, LineSocketCtor} from "./transport"; + +/** The shell bridge, as `client/js/helpers/shell.ts`'s `SeanceShell` has it. */ +export interface SocketBridge { + call(plugin: string, method: string, options?: unknown): Promise; + listen(plugin: string, event: string, cb: (data: any) => void): void; +} + +/** Where to dial. `host` is a bare name or IP literal (no brackets, no path). */ +export interface TcpEndpoint { + host: string; + port: number; + tls: boolean; +} + +const PLUGIN = "Socket"; + +type Listeners = Record<"open" | "message" | "error" | "close", Array<(ev: any) => void>>; + +/** Live sockets per bridge, by id; the bridge's events are routed through here. */ +const routes = new WeakMap>(); + +function routesFor(bridge: SocketBridge): Map { + const map = routes.get(bridge); + + if (map) { + return map; + } + + const sockets = new Map(); + routes.set(bridge, sockets); + const route = (data: any, fn: (s: ShellSocket, d: any) => void) => { + const socket = typeof data?.id === "string" ? sockets.get(data.id) : undefined; + + if (socket) { + fn(socket, data); + } + }; + bridge.listen(PLUGIN, "open", (data) => route(data, (s) => s.onShellOpen())); + bridge.listen(PLUGIN, "lines", (data) => + route(data, (s, d) => s.onShellLines(Array.isArray(d.lines) ? d.lines : [])) + ); + bridge.listen(PLUGIN, "close", (data) => + route(data, (s, d) => + s.onShellClose( + typeof d.code === "number" ? d.code : 1006, + typeof d.reason === "string" ? d.reason : "" + ) + ) + ); + return sockets; +} + +function messageOf(err: unknown): string { + const msg = (err as {message?: unknown} | null)?.message; + return typeof msg === "string" && msg.length > 0 ? msg : String(err); +} + +function newId(): string { + return globalThis.crypto.randomUUID(); +} + +export class ShellSocket implements LineSocket { + readonly CONNECTING = 0; + readonly OPEN = 1; + readonly CLOSING = 2; + readonly CLOSED = 3; + readonly protocol = ""; + binaryType = "arraybuffer"; + readonly id: string; + + private state = 0; + private readonly listeners: Listeners = {open: [], message: [], error: [], close: []}; + private readonly sockets: Map; + /** The shell calls, in order: open, then sends, then close. */ + private chain: Promise; + /** Lines sent this tick, to go out as one call. */ + private outbox: string[] = []; + private flushQueued = false; + + constructor( + private readonly bridge: SocketBridge, + endpoint: TcpEndpoint, + id: string = newId() + ) { + this.id = id; + this.sockets = routesFor(bridge); + this.sockets.set(id, this); + this.chain = bridge + .call(PLUGIN, "open", {id, host: endpoint.host, port: endpoint.port, tls: endpoint.tls}) + .catch((err: unknown) => this.onShellClose(1006, messageOf(err))); + } + + get readyState(): number { + return this.state; + } + + addEventListener(type: "open", listener: () => void): void; + addEventListener(type: "message", listener: (ev: {data: unknown}) => void): void; + addEventListener(type: "error", listener: (ev: unknown) => void): void; + addEventListener( + type: "close", + listener: (ev: {code: number; reason: string; wasClean: boolean}) => void + ): void; + addEventListener(type: keyof Listeners, listener: (ev: any) => void): void { + this.listeners[type].push(listener); + } + + send(line: string): void { + if (this.state !== this.OPEN) { + throw new Error("ShellSocket: not open"); + } + + this.outbox.push(line); + + if (!this.flushQueued) { + this.flushQueued = true; + queueMicrotask(() => this.flush()); + } + } + + close(): void { + if (this.state === this.CLOSING || this.state === this.CLOSED) { + return; + } + + const wasOpen = this.state === this.OPEN; + this.flush(); // lines sent this tick (a QUIT) go before the close + this.state = this.CLOSING; + this.enqueue(() => this.bridge.call(PLUGIN, "close", {id: this.id}), false); + queueMicrotask(() => + this.finish(wasOpen ? 1000 : 1006, wasOpen ? "" : "closed while connecting") + ); + } + + /** @internal The shell connected. */ + onShellOpen(): void { + if (this.state !== this.CONNECTING) { + return; + } + + this.state = this.OPEN; + this.fire("open", undefined); + } + + /** @internal Lines from the server. */ + onShellLines(lines: unknown[]): void { + for (const line of lines) { + if (this.state !== this.OPEN) { + return; // a listener closed the socket mid-batch + } + + if (typeof line === "string") { + this.fire("message", {data: line}); + } + } + } + + /** @internal The shell lost the socket, or refused a call. */ + onShellClose(code: number, reason: string): void { + if (this.state === this.CLOSING || this.state === this.CLOSED) { + return; // a page close fires its own event + } + + if (code !== 1000) { + this.fire("error", {message: reason || "connection failed"}); + } + + this.finish(code, reason); + } + + private flush(): void { + this.flushQueued = false; + + if (this.outbox.length === 0 || this.state !== this.OPEN) { + this.outbox = []; + return; + } + + const lines = this.outbox; + this.outbox = []; + this.enqueue(() => this.bridge.call(PLUGIN, "send", {id: this.id, lines}), true); + } + + private enqueue(call: () => Promise, failCloses: boolean): void { + this.chain = this.chain.then(call).catch((err: unknown) => { + if (failCloses) { + this.onShellClose(1006, messageOf(err)); + } + }); + } + + private finish(code: number, reason: string): void { + if (this.state === this.CLOSED) { + return; + } + + this.state = this.CLOSED; + this.sockets.delete(this.id); + this.fire("close", {code, reason, wasClean: code === 1000}); + } + + private fire(type: K, ev: unknown): void { + for (const listener of [...this.listeners[type]]) { + listener(ev); + } + } +} + +/** A socket constructor for {@link WsTransport}, bound to one endpoint; the URL it is given is only for show. */ +export function shellSocketCtor(bridge: SocketBridge, endpoint: TcpEndpoint): LineSocketCtor { + return class extends ShellSocket { + constructor(_url: string, _protocols?: string | string[]) { + super(bridge, endpoint); + } + }; +} diff --git a/client/js/irc/sts.ts b/client/js/irc/sts.ts index 103d37e3d..cfd70b95d 100644 --- a/client/js/irc/sts.ts +++ b/client/js/irc/sts.ts @@ -50,6 +50,16 @@ export function policyHost(host: string): string { return hostnameOf(host).toLowerCase(); } +/** + * The host name STS policies are stored under for a connection: the host for + * WebSocket, `tcp:host` for IRC over TCP — the two reach one server on + * different secure ports, so a policy learned on one says nothing about the + * other's port. + */ +export function stsHost(opts: Pick): string { + return opts.transport === "tcp" ? `tcp:${hostnameOf(opts.host)}` : opts.host; +} + /** Parse `port=6697,duration=300,preload`; unknown keys ignored, bad numbers dropped. */ export function parseStsValue(value: string): StsValue { const result: StsValue = {preload: false}; @@ -250,7 +260,7 @@ export function upgradeOptions(opts: T, now: number = return opts; } - const policy = getPolicy(opts.host, now); + const policy = getPolicy(stsHost(opts), now); if (!policy) { return opts; diff --git a/client/js/irc/transport.ts b/client/js/irc/transport.ts index a8c2761cd..707cca063 100644 --- a/client/js/irc/transport.ts +++ b/client/js/irc/transport.ts @@ -31,6 +31,29 @@ * the foreground poke leaves them be, and only Connect now cuts them. */ +/** + * The part of `WebSocket` that {@link WsTransport} uses, and all a socket + * underneath it has to provide: the browser's `WebSocket` is one, the app + * shell's TCP socket (shellSocket.ts) another. + */ +export interface LineSocket { + readonly readyState: number; + readonly OPEN: number; + readonly protocol: string; + binaryType: string; + addEventListener(type: "open", listener: () => void): void; + addEventListener(type: "message", listener: (ev: {data: unknown}) => void): void; + addEventListener(type: "error", listener: (ev: unknown) => void): void; + addEventListener( + type: "close", + listener: (ev: {code: number; reason: string; wasClean: boolean}) => void + ): void; + send(data: string): void; + close(code?: number, reason?: string): void; +} + +export type LineSocketCtor = new (url: string, protocols?: string | string[]) => LineSocket; + export interface ReconnectOptions { enabled: boolean; initialDelayMs: number; @@ -51,8 +74,8 @@ export interface TransportOptions { * used to drop the connection on frames >= 528 bytes, #98, fixed 2026-08-28). */ maxLineBytes?: number; - /** WebSocket constructor to use instead of `globalThis.WebSocket`. */ - WebSocketImpl?: typeof WebSocket; + /** Socket constructor to use instead of `globalThis.WebSocket`. */ + WebSocketImpl?: LineSocketCtor; } /** Why a retry waits, when it is not the schedule's own wait. */ @@ -117,7 +140,7 @@ export class WsTransport { Pick; private readonly listeners = new Set<(ev: TransportEvent) => void>(); private readonly decoder = new TextDecoder(); - private ws: WebSocket | null = null; + private ws: LineSocket | null = null; private _state: TransportState = "closed"; private attempt = 0; private timer: ReturnType | null = null; @@ -183,14 +206,14 @@ export class WsTransport { this.hold = false; // A superseded socket's open says nothing about this one. this.openedAt = null; - const Impl = this.opts.WebSocketImpl ?? globalThis.WebSocket; + const Impl: LineSocketCtor | undefined = this.opts.WebSocketImpl ?? globalThis.WebSocket; if (typeof Impl !== "function") { throw new Error("WsTransport: no WebSocket implementation available"); } this._state = "connecting"; - let ws: WebSocket; + let ws: LineSocket; try { ws = new Impl(this.opts.url, this.opts.subprotocols); @@ -222,17 +245,17 @@ export class WsTransport { this.emit({type: "open", subprotocol: ws.protocol}); } }); - ws.addEventListener("message", (ev: MessageEvent) => { + ws.addEventListener("message", (ev) => { if (ws === this.ws) { this.handleMessage(ev.data); } }); - ws.addEventListener("error", (ev: Event) => { + ws.addEventListener("error", (ev) => { if (ws === this.ws) { this.emit({type: "error", message: errorMessage(ev)}); } }); - ws.addEventListener("close", (ev: CloseEvent) => { + ws.addEventListener("close", (ev) => { if (ws === this.ws) { this.ws = null; this.handleClosed(ev.code, ev.reason, ev.wasClean); diff --git a/client/js/irc/types.ts b/client/js/irc/types.ts index 66b2b6825..73654e11d 100644 --- a/client/js/irc/types.ts +++ b/client/js/irc/types.ts @@ -40,6 +40,11 @@ export type ConnectOptions = { host: string; port: number; tls: boolean; + /** + * `tcp`: plain IRC over TCP (TLS when `tls`), made by the app shell + * (shellSocket.ts); unavailable in a browser. Absent: IRC over WebSocket. + */ + transport?: "tcp"; nick: string; /** Comma-separated channel list, optionally with keys (`#a key, #b`). */ join: string; diff --git a/client/locales/en.json b/client/locales/en.json index 5f1fb734b..0fa533e65 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -208,6 +208,9 @@ "connect.connectingAs": "Connecting as {nick} to {network}…", "connect.connectingTo": "Connecting to {host}:{port}…", "connect.connectingToAttempt": "Connecting to {host}:{port}… (attempt {attempt})", + "connect.connection": "Connection", + "connect.connection.tcp": "IRC over TCP", + "connect.connection.websocket": "WebSocket", "connect.disconnected": "Disconnected.", "connect.enabledCaps": "Enabled capabilities: {caps}", "connect.enterPassword": "Enter the password for {account} to connect.", @@ -254,6 +257,7 @@ "connect.stsReconnect": "Server requires a secure connection (STS): reconnecting on port {port}…", "connect.stsUpgrade": "Upgrading to TLS on port {port} (STS policy)", "connect.submit": "Connect", + "connect.tcpNeedsApp": "IRC over TCP works only in the app. A browser can connect over WebSocket only.", "connect.title": "Connect to IRC", "connect.useTls": "Use secure connection (TLS)", "connect.user": "User", @@ -280,6 +284,8 @@ "disconnect.hint.plainCauses": "The browser does not reveal why. Likely causes: the server is down, the port is wrong, or it does not accept WebSocket connections on that port.", "disconnect.hint.registration": "The WebSocket opened but the server dropped it before registration finished - check that this port really speaks IRC over WebSocket, and look for a server notice above for the reason.", "disconnect.hint.sessionConflict": "This account is already connected on this network - another tab, window or device is using it. Close that client; this tab then connects on its next retry (a dead connection is reclaimed within about a minute).", + "disconnect.hint.tcpPlainCauses": "Likely causes: the server is down, the port is wrong, or something on the network blocks the connection.", + "disconnect.hint.tcpTlsCauses": "Likely causes: the server is down, the port is wrong, or its certificate is not trusted. A server with a self-signed certificate can only be reached without TLS for now.", "disconnect.hint.wssCauses": "The browser does not reveal why. Likely causes: the server is down, the port is wrong, or its TLS certificate is not trusted — for a self-signed certificate, open {url} in a new tab, accept the warning, then reconnect.", "disconnect.registeringClosed": "Connection to {host} closed during IRC registration{detail}.", "disconnect.registeringClosedNotReconnecting": "Connection to {host} closed during IRC registration{detail}. Not reconnecting.", diff --git a/client/locales/messages.pot b/client/locales/messages.pot index 0690faf14..43558f13a 100644 --- a/client/locales/messages.pot +++ b/client/locales/messages.pot @@ -1012,6 +1012,25 @@ msgctxt "connect.connectingToAttempt" msgid "Connecting to {host}:{port}… (attempt {attempt})" msgstr "" +#. Label of the select choosing how the app reaches the IRC server: over WebSocket or over a raw TCP connection. Shown only in the app. +#: client/components/Windows/Connect.vue +#: client/components/NetworkForm.vue +msgctxt "connect.connection" +msgid "Connection" +msgstr "" + +#. Option of the Connection select: a classic IRC connection over raw TCP, ports 6667 or 6697. Only the app can make it. +#: client/components/Windows/Connect.vue +msgctxt "connect.connection.tcp" +msgid "IRC over TCP" +msgstr "" + +#. Option of the Connection select: IRC carried over a WebSocket, what browsers use. +#: client/components/Windows/Connect.vue +msgctxt "connect.connection.websocket" +msgid "WebSocket" +msgstr "" + #. Lobby status line when the user's own /quit closed the connection on purpose. #: client/js/irc/client.ts msgctxt "connect.disconnected" @@ -1293,6 +1312,12 @@ msgctxt "connect.submit" msgid "Connect" msgstr "" +#. Lobby error when a network set to IRC over TCP is opened in a web browser, which cannot open raw TCP connections. +#: client/js/irc/client.ts +msgctxt "connect.tcpNeedsApp" +msgid "IRC over TCP works only in the app. A browser can connect over WebSocket only." +msgstr "" + #. Heading of the connect form (features.signIn off). #: client/js/branding.ts msgctxt "connect.title" @@ -1449,6 +1474,18 @@ msgctxt "disconnect.hint.sessionConflict" msgid "This account is already connected on this network - another tab, window or device is using it. Close that client; this tab then connects on its next retry (a dead connection is reclaimed within about a minute)." msgstr "" +#. Hint after a plain (no TLS) raw TCP connection failed to open, in the app. +#: client/js/irc/disconnect.ts +msgctxt "disconnect.hint.tcpPlainCauses" +msgid "Likely causes: the server is down, the port is wrong, or something on the network blocks the connection." +msgstr "" + +#. Hint after a TLS connection over raw TCP (not WebSocket) failed to open, in the app. +#: client/js/irc/disconnect.ts +msgctxt "disconnect.hint.tcpTlsCauses" +msgid "Likely causes: the server is down, the port is wrong, or its certificate is not trusted. A server with a self-signed certificate can only be reached without TLS for now." +msgstr "" + #. Follow-up hint under a failed secure (wss://) dial. {url} is the network's address with https: in place of wss:, for accepting a self-signed certificate once. #: client/js/irc/disconnect.ts msgctxt "disconnect.hint.wssCauses" diff --git a/client/locales/qqx.json b/client/locales/qqx.json index 472cebd15..ee3159642 100644 --- a/client/locales/qqx.json +++ b/client/locales/qqx.json @@ -208,6 +208,9 @@ "connect.connectingAs": "‫ċöññéċţïñġ àś {nick} ţö {network}…ċöññéċţïñġ àś {nick} ţö {network}…‬", "connect.connectingTo": "‫ċöññéċţïñġ ţö {host}:{port}…ċöññéċţïñġ ţö {host}:{port}…‬", "connect.connectingToAttempt": "‫ċöññéċţïñġ ţö {host}:{port}… )àţţéṁṗţ {attempt}(ċöññéċţïñġ ţö {host}:{port}… )àţţéṁṗţ {attempt}(‬", + "connect.connection": "‫ċöññéċţïöñċöññéċţïöñ‬", + "connect.connection.tcp": "‫ïŕċ öṽéŕ ţċṗïŕċ öṽéŕ ţċṗ‬", + "connect.connection.websocket": "‫ẃéḃśöċķéţẃéḃśöċķéţ‬", "connect.disconnected": "‫ḋïśċöññéċţéḋ.ḋïśċöññéċţéḋ.‬", "connect.enabledCaps": "‫éñàḃľéḋ ċàṗàḃïľïţïéś: {caps}éñàḃľéḋ ċàṗàḃïľïţïéś: {caps}‬", "connect.enterPassword": "‫éñţéŕ ţḣé ṗàśśẃöŕḋ ḟöŕ {account} ţö ċöññéċţ.éñţéŕ ţḣé ṗàśśẃöŕḋ ḟöŕ {account} ţö ċöññéċţ.‬", @@ -253,6 +256,7 @@ "connect.stsReconnect": "‫śéŕṽéŕ ŕéq́üïŕéś à śéċüŕé ċöññéċţïöñ )śţś(: ŕéċöññéċţïñġ öñ ṗöŕţ {port}…śéŕṽéŕ ŕéq́üïŕéś à śéċüŕé ċöññéċţïöñ )śţś(: ŕéċöññéċţïñġ öñ ṗöŕţ {port}…‬", "connect.stsUpgrade": "‫üṗġŕàḋïñġ ţö ţľś öñ ṗöŕţ {port} )śţś ṗöľïċý(üṗġŕàḋïñġ ţö ţľś öñ ṗöŕţ {port} )śţś ṗöľïċý(‬", "connect.submit": "‫ċöññéċţċöññéċţ‬", + "connect.tcpNeedsApp": "‫ïŕċ öṽéŕ ţċṗ ẃöŕķś öñľý ïñ ţḣé àṗṗ. à ḃŕöẃśéŕ ċàñ ċöññéċţ öṽéŕ ẃéḃśöċķéţ öñľý.ïŕċ öṽéŕ ţċṗ ẃöŕķś öñľý ïñ ţḣé àṗṗ. à ḃŕöẃśéŕ ċàñ ċöññéċţ öṽéŕ ẃéḃśöċķéţ öñľý.‬", "connect.title": "‫ċöññéċţ ţö ïŕċċöññéċţ ţö ïŕċ‬", "connect.useTls": "‫üśé śéċüŕé ċöññéċţïöñ )ţľś(üśé śéċüŕé ċöññéċţïöñ )ţľś(‬", "connect.user": "‫üśéŕüśéŕ‬", @@ -279,6 +283,8 @@ "disconnect.hint.plainCauses": "‫ţḣé ḃŕöẃśéŕ ḋöéś ñöţ ŕéṽéàľ ẃḣý. ľïķéľý ċàüśéś: ţḣé śéŕṽéŕ ïś ḋöẃñ, ţḣé ṗöŕţ ïś ẃŕöñġ, öŕ ïţ ḋöéś ñöţ àċċéṗţ ẃéḃśöċķéţ ċöññéċţïöñś öñ ţḣàţ ṗöŕţ.ţḣé ḃŕöẃśéŕ ḋöéś ñöţ ŕéṽéàľ ẃḣý. ľïķéľý ċàüśéś: ţḣé śéŕṽéŕ ïś ḋöẃñ, ţḣé ṗöŕţ ïś ẃŕöñġ, öŕ ïţ ḋöéś ñöţ àċċéṗţ ẃéḃśöċķéţ ċöññéċţïöñś öñ ţḣàţ ṗöŕţ.‬", "disconnect.hint.registration": "‫ţḣé ẃéḃśöċķéţ öṗéñéḋ ḃüţ ţḣé śéŕṽéŕ ḋŕöṗṗéḋ ïţ ḃéḟöŕé ŕéġïśţŕàţïöñ ḟïñïśḣéḋ - ċḣéċķ ţḣàţ ţḣïś ṗöŕţ ŕéàľľý śṗéàķś ïŕċ öṽéŕ ẃéḃśöċķéţ, àñḋ ľööķ ḟöŕ à śéŕṽéŕ ñöţïċé àḃöṽé ḟöŕ ţḣé ŕéàśöñ.ţḣé ẃéḃśöċķéţ öṗéñéḋ ḃüţ ţḣé śéŕṽéŕ ḋŕöṗṗéḋ ïţ ḃéḟöŕé ŕéġïśţŕàţïöñ ḟïñïśḣéḋ - ċḣéċķ ţḣàţ ţḣïś ṗöŕţ ŕéàľľý śṗéàķś ïŕċ öṽéŕ ẃéḃśöċķéţ, àñḋ ľööķ ḟöŕ à śéŕṽéŕ ñöţïċé àḃöṽé ḟöŕ ţḣé ŕéàśöñ.‬", "disconnect.hint.sessionConflict": "‫ţḣïś àċċöüñţ ïś àľŕéàḋý ċöññéċţéḋ öñ ţḣïś ñéţẃöŕķ - àñöţḣéŕ ţàḃ, ẃïñḋöẃ öŕ ḋéṽïċé ïś üśïñġ ïţ. ċľöśé ţḣàţ ċľïéñţ; ţḣïś ţàḃ ţḣéñ ċöññéċţś öñ ïţś ñéx́ţ ŕéţŕý )à ḋéàḋ ċöññéċţïöñ ïś ŕéċľàïṁéḋ ẃïţḣïñ àḃöüţ à ṁïñüţé(.ţḣïś àċċöüñţ ïś àľŕéàḋý ċöññéċţéḋ öñ ţḣïś ñéţẃöŕķ - àñöţḣéŕ ţàḃ, ẃïñḋöẃ öŕ ḋéṽïċé ïś üśïñġ ïţ. ċľöśé ţḣàţ ċľïéñţ; ţḣïś ţàḃ ţḣéñ ċöññéċţś öñ ïţś ñéx́ţ ŕéţŕý )à ḋéàḋ ċöññéċţïöñ ïś ŕéċľàïṁéḋ ẃïţḣïñ àḃöüţ à ṁïñüţé(.‬", + "disconnect.hint.tcpPlainCauses": "‫ľïķéľý ċàüśéś: ţḣé śéŕṽéŕ ïś ḋöẃñ, ţḣé ṗöŕţ ïś ẃŕöñġ, öŕ śöṁéţḣïñġ öñ ţḣé ñéţẃöŕķ ḃľöċķś ţḣé ċöññéċţïöñ.ľïķéľý ċàüśéś: ţḣé śéŕṽéŕ ïś ḋöẃñ, ţḣé ṗöŕţ ïś ẃŕöñġ, öŕ śöṁéţḣïñġ öñ ţḣé ñéţẃöŕķ ḃľöċķś ţḣé ċöññéċţïöñ.‬", + "disconnect.hint.tcpTlsCauses": "‫ľïķéľý ċàüśéś: ţḣé śéŕṽéŕ ïś ḋöẃñ, ţḣé ṗöŕţ ïś ẃŕöñġ, öŕ ïţś ċéŕţïḟïċàţé ïś ñöţ ţŕüśţéḋ. à śéŕṽéŕ ẃïţḣ à śéľḟ-śïġñéḋ ċéŕţïḟïċàţé ċàñ öñľý ḃé ŕéàċḣéḋ ẃïţḣöüţ ţľś ḟöŕ ñöẃ.ľïķéľý ċàüśéś: ţḣé śéŕṽéŕ ïś ḋöẃñ, ţḣé ṗöŕţ ïś ẃŕöñġ, öŕ ïţś ċéŕţïḟïċàţé ïś ñöţ ţŕüśţéḋ. à śéŕṽéŕ ẃïţḣ à śéľḟ-śïġñéḋ ċéŕţïḟïċàţé ċàñ öñľý ḃé ŕéàċḣéḋ ẃïţḣöüţ ţľś ḟöŕ ñöẃ.‬", "disconnect.hint.wssCauses": "‫ţḣé ḃŕöẃśéŕ ḋöéś ñöţ ŕéṽéàľ ẃḣý. ľïķéľý ċàüśéś: ţḣé śéŕṽéŕ ïś ḋöẃñ, ţḣé ṗöŕţ ïś ẃŕöñġ, öŕ ïţś ţľś ċéŕţïḟïċàţé ïś ñöţ ţŕüśţéḋ — ḟöŕ à śéľḟ-śïġñéḋ ċéŕţïḟïċàţé, öṗéñ {url} ïñ à ñéẃ ţàḃ, àċċéṗţ ţḣé ẃàŕñïñġ, ţḣéñ ŕéċöññéċţ.ţḣé ḃŕöẃśéŕ ḋöéś ñöţ ŕéṽéàľ ẃḣý. ľïķéľý ċàüśéś: ţḣé śéŕṽéŕ ïś ḋöẃñ, ţḣé ṗöŕţ ïś ẃŕöñġ, öŕ ïţś ţľś ċéŕţïḟïċàţé ïś ñöţ ţŕüśţéḋ — ḟöŕ à śéľḟ-śïġñéḋ ċéŕţïḟïċàţé, öṗéñ {url} ïñ à ñéẃ ţàḃ, àċċéṗţ ţḣé ẃàŕñïñġ, ţḣéñ ŕéċöññéċţ.‬", "disconnect.registeringClosed": "‫ċöññéċţïöñ ţö {host} ċľöśéḋ ḋüŕïñġ ïŕċ ŕéġïśţŕàţïöñ{detail}.ċöññéċţïöñ ţö {host} ċľöśéḋ ḋüŕïñġ ïŕċ ŕéġïśţŕàţïöñ{detail}.‬", "disconnect.registeringClosedNotReconnecting": "‫ċöññéċţïöñ ţö {host} ċľöśéḋ ḋüŕïñġ ïŕċ ŕéġïśţŕàţïöñ{detail}. ñöţ ŕéċöññéċţïñġ.ċöññéċţïöñ ţö {host} ċľöśéḋ ḋüŕïñġ ïŕċ ŕéġïśţŕàţïöñ{detail}. ñöţ ŕéċöññéċţïñġ.‬", diff --git a/shells/tauri/src-tauri/Cargo.lock b/shells/tauri/src-tauri/Cargo.lock index 1f99500b6..afb2271b9 100644 --- a/shells/tauri/src-tauri/Cargo.lock +++ b/shells/tauri/src-tauri/Cargo.lock @@ -2908,6 +2908,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -2946,6 +2960,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3029,6 +3078,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "objc2-web-kit", + "rustls", "serde", "serde_json", "swift-rs", @@ -3039,7 +3089,10 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-single-instance", "tauri-plugin-window-state", + "tokio", + "tokio-rustls", "webkit2gtk", + "webpki-roots", "zbus", ] @@ -3382,6 +3435,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.9" @@ -3972,9 +4031,31 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.20" @@ -4223,6 +4304,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4485,6 +4572,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.39.1" @@ -4684,7 +4780,16 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-targets", + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -4702,13 +4807,29 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -4735,42 +4856,90 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.5.40" @@ -4989,6 +5158,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/shells/tauri/src-tauri/Cargo.toml b/shells/tauri/src-tauri/Cargo.toml index c785c484b..546bc87c7 100644 --- a/shells/tauri/src-tauri/Cargo.toml +++ b/shells/tauri/src-tauri/Cargo.toml @@ -22,6 +22,15 @@ tauri-plugin-deep-link = "=2.6.3" tauri-plugin-opener = "=2.7.2" serde = { version = "1", features = ["derive"] } serde_json = "1" +# IRC over TCP (src/socket.rs). rustls on ring, not the default aws-lc-rs, +# so no cmake/NASM in the Windows and phone builds; Mozilla's roots, built in. +tokio = { version = "=1.54.1", features = ["net", "io-util", "sync", "time", "macros"] } +tokio-rustls = { version = "=0.26.6", default-features = false, features = ["ring", "logging", "tls12"] } +rustls = { version = "=0.23.46", default-features = false, features = ["ring", "std", "logging", "tls12"] } +webpki-roots = "=1.0.11" + +[dev-dependencies] +tokio = { version = "=1.54.1", features = ["rt", "macros", "net", "io-util"] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-single-instance = "=2.5.5" diff --git a/shells/tauri/src-tauri/src/lib.rs b/shells/tauri/src-tauri/src/lib.rs index 2c7597f0b..70c6fcd02 100644 --- a/shells/tauri/src-tauri/src/lib.rs +++ b/shells/tauri/src-tauri/src/lib.rs @@ -2,7 +2,8 @@ // on the phones. The window loads the built SPA from the // bundle — `public/`, compiled in by `frontendDist` — and the page speaks IRC // over WebSocket itself; the shell adds a window, links, the badge and the -// platform's close behaviour. +// platform's close behaviour, and raw TCP/TLS sockets for networks with no +// WebSocket listener (src/socket.rs). // // The page reaches the shell through `window.seanceShell` (the init script // below), the contract client/js/helpers/shell.ts reads in every shell: @@ -16,6 +17,7 @@ mod alerts; mod apple; #[cfg(mobile)] mod mobile; +mod socket; use serde::Serialize; use serde_json::{json, Value}; @@ -42,7 +44,9 @@ const PLATFORM: &str = "ios"; /// object is frozen and neither it nor the emit hook can be replaced, so a /// page script cannot get between the page and the shell. It can still call /// the hook and fake an event, because `emit()` evaluates in the page's own -/// world; events carry nothing the page could not do itself. A retained +/// world; events carry nothing the page could not do itself. Calls are +/// another matter: `Socket` opens raw connections, which no web page can, +/// and so enforces its own limits (src/socket.rs). A retained /// event nobody listens to yet waits for its first listener. fn init_script(version: &str) -> String { format!( @@ -251,6 +255,7 @@ async fn seance_call( smoke::report(window.app_handle(), &options); Ok(json!({})) } + ("Socket", _) => socket::call(window.app_handle(), &method, &options), _ => native(window.app_handle(), &plugin, &method, &options).await, } } @@ -403,6 +408,8 @@ fn create_main(app: &AppHandle) -> tauri::Result { if started { handle.state::>().lock().unwrap().listening = false; + // The old page's connections go with it. + handle.state::().close_all(); } let waiting = { @@ -573,6 +580,7 @@ pub fn run() { listening: false, })) .manage(Mutex::new(Held::default())) + .manage(socket::Sockets::default()) .invoke_handler(tauri::generate_handler![seance_call]) .setup(|app| { let handle = app.handle().clone(); diff --git a/shells/tauri/src-tauri/src/socket.rs b/shells/tauri/src-tauri/src/socket.rs new file mode 100644 index 000000000..2b13eeb22 --- /dev/null +++ b/shells/tauri/src-tauri/src/socket.rs @@ -0,0 +1,493 @@ +// Raw TCP/TLS IRC connections for the page (client/js/irc/shellSocket.ts), +// for networks with no WebSocket listener. The page's WsTransport keeps its +// whole connection policy; this only moves lines: CRLF framing out, lines +// split and decoded in, TLS against Mozilla's roots (webpki-roots). +// +// Unlike the shell's other calls this does something a web page cannot, so +// every limit is enforced here, not trusted to the page: ports on the Fetch +// spec's bad-ports list (SMTP, POP, IMAP, …) are refused, a sent line may +// hold no CR, LF or NUL, lines and queues are capped, and there are at most +// MAX_SOCKETS at once. +// +// `Socket.open {id, host, port, tls}` → events `open {id}`, `lines {id, +// lines}` (one per read), `close {id, code, reason}`: 1000 when the server +// ended the connection, 1006 for any failure. `Socket.send {id, lines}`. +// `Socket.close {id}`: the queued lines are written (a QUIT gets out), the +// socket shut, and no event follows. A page load closes every socket. + +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tauri::{AppHandle, Manager}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::sync::mpsc; + +use crate::ShellError; + +const PLUGIN: &str = "Socket"; +const MAX_SOCKETS: usize = 16; +/// Queued outbound lines per socket; past it the socket is closed. +const SEND_QUEUE: usize = 512; +/// A sent line, before CRLF. The page caps lines at 500 bytes already. +const MAX_SEND_LINE: usize = 4096; +/// A received line; an ircd's own limit is 8191 + 512 bytes of tags. +pub const MAX_LINE: usize = 16 * 1024; +const ADDRESS_TIMEOUT: Duration = Duration::from_secs(5); +const DIAL_DEADLINE: Duration = Duration::from_secs(20); +const DRAIN_TIMEOUT: Duration = Duration::from_secs(2); + +/// The Fetch spec's bad ports (https://fetch.spec.whatwg.org/#port-blocking), +/// less the IRC ones on it (6665–6669, 6679, 6697). +const BAD_PORTS: &[u16] = &[ + 0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, + 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, + 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, + 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 10080, +]; + +/// The live sockets: each one's queue of lines to write. Dropping a sender +/// is how a socket is closed — its task drains what is queued and ends. +#[derive(Default)] +pub struct Sockets(Mutex>>); + +impl Sockets { + /// A page load: the old page's sockets go with it. + pub fn close_all(&self) { + self.0.lock().unwrap().clear(); + } +} + +fn refuse(message: impl Into) -> ShellError { + ShellError { + message: message.into(), + code: "refused".into(), + } +} + +fn str_option<'a>(options: &'a Value, key: &str) -> Option<&'a str> { + options.get(key).and_then(Value::as_str) +} + +pub fn call(app: &AppHandle, method: &str, options: &Value) -> Result { + let sockets = app.state::(); + let id = str_option(options, "id") + .filter(|id| !id.is_empty() && id.len() <= 64) + .ok_or_else(|| refuse("no socket id"))? + .to_owned(); + + match method { + "open" => { + let host = str_option(options, "host") + .map(clean_host) + .unwrap_or_default(); + let port = options.get("port").and_then(Value::as_u64).unwrap_or(0); + let tls = options.get("tls").and_then(Value::as_bool).unwrap_or(false); + + if host.is_empty() || host.len() > 255 { + return Err(refuse("no host")); + } + + let port = u16::try_from(port) + .ok() + .filter(|p| port_allowed(*p)) + .ok_or_else(|| refuse(format!("port {port} is not allowed")))?; + let (tx, rx) = mpsc::channel(SEND_QUEUE); + + { + let mut map = sockets.0.lock().unwrap(); + + if map.contains_key(&id) { + return Err(refuse("socket id in use")); + } + + if map.len() >= MAX_SOCKETS { + return Err(refuse("too many connections")); + } + + // In the map before the task starts: a close that comes + // while it dials finds it, and the dial is abandoned. + map.insert(id.clone(), tx.clone()); + } + + let app = app.clone(); + tauri::async_runtime::spawn(run(app, id, host, port, tls, tx, rx)); + Ok(json!({})) + } + "send" => { + let lines = options + .get("lines") + .and_then(Value::as_array) + .ok_or_else(|| refuse("no lines"))?; + let tx = sockets + .0 + .lock() + .unwrap() + .get(&id) + .cloned() + .ok_or_else(|| refuse("no such socket"))?; + + for line in lines { + let line = line.as_str().ok_or_else(|| refuse("a line is not text"))?; + + if let Err(reason) = check_send_line(line) { + drop_socket(app, &id, 1006, reason); + return Err(refuse(reason)); + } + + if tx.try_send(line.to_owned()).is_err() { + drop_socket(app, &id, 1006, "send queue full"); + return Err(refuse("send queue full")); + } + } + + Ok(json!({})) + } + "close" => { + sockets.0.lock().unwrap().remove(&id); + Ok(json!({})) + } + _ => Err(ShellError { + message: format!("{PLUGIN}.{method} is not implemented"), + code: "unimplemented".into(), + }), + } +} + +/// Close a socket the page did not ask to close, and tell it why. +fn drop_socket(app: &AppHandle, id: &str, code: u16, reason: &str) { + if app + .state::() + .0 + .lock() + .unwrap() + .remove(id) + .is_some() + { + crate::emit( + app, + PLUGIN, + "close", + json!({"id": id, "code": code, "reason": reason}), + false, + ); + } +} + +/// Our socket ended on its own (the server, the network): tell the page, +/// unless the page closed it or a newer page owns the map. +fn finished(app: &AppHandle, id: &str, tx: &mpsc::Sender, code: u16, reason: &str) { + let sockets = app.state::(); + let mut map = sockets.0.lock().unwrap(); + + if map.get(id).is_some_and(|ours| ours.same_channel(tx)) { + map.remove(id); + drop(map); + crate::emit( + app, + PLUGIN, + "close", + json!({"id": id, "code": code, "reason": reason}), + false, + ); + } +} + +trait Stream: AsyncRead + AsyncWrite + Send + Unpin {} +impl Stream for T {} + +async fn run( + app: AppHandle, + id: String, + host: String, + port: u16, + tls: bool, + tx: mpsc::Sender, + mut rx: mpsc::Receiver, +) { + // Our own sender is held only to recognise our map entry; the page's + // close is the map dropping its copy, seen as `recv()` → None below + // once this one is gone too. + let marker = tx.downgrade(); + drop(tx); + + let dial = tokio::time::timeout(DIAL_DEADLINE, dial(&host, port, tls)); + let stream = tokio::select! { + result = dial => match result { + Ok(Ok(stream)) => stream, + Ok(Err(reason)) => return end(&app, &id, &marker, 1006, &reason), + Err(_) => return end(&app, &id, &marker, 1006, "connect timed out"), + }, + // Closed by the page while dialling: give up quietly. + None = rx.recv() => return, + }; + + crate::emit(&app, PLUGIN, "open", json!({ "id": id }), false); + let (mut reader, mut writer) = tokio::io::split(stream); + let mut buf: Vec = Vec::new(); + let mut chunk = vec![0u8; 16 * 1024]; + + loop { + tokio::select! { + read = reader.read(&mut chunk) => match read { + Ok(0) => return end(&app, &id, &marker, 1000, "the server closed the connection"), + Ok(n) => { + buf.extend_from_slice(&chunk[..n]); + + match take_lines(&mut buf) { + Ok(lines) if lines.is_empty() => {} + Ok(lines) => crate::emit(&app, PLUGIN, "lines", json!({"id": id, "lines": lines}), false), + Err(reason) => return end(&app, &id, &marker, 1006, reason), + } + } + // Most ircds drop the connection without TLS's close_notify. + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + return end(&app, &id, &marker, 1000, "the server closed the connection") + } + Err(e) => return end(&app, &id, &marker, 1006, &e.to_string()), + }, + line = rx.recv() => match line { + Some(line) => { + let mut bytes = line.into_bytes(); + bytes.extend_from_slice(b"\r\n"); + + if let Err(e) = writer.write_all(&bytes).await { + return end(&app, &id, &marker, 1006, &e.to_string()); + } + } + // Closed by the page (or a page load): everything queued has + // been written by now; flush, shut down, quietly. + None => { + let _ = tokio::time::timeout(DRAIN_TIMEOUT, async { + let _ = writer.flush().await; + let _ = writer.shutdown().await; + }) + .await; + return; + } + }, + } + } +} + +fn end(app: &AppHandle, id: &str, marker: &mpsc::WeakSender, code: u16, reason: &str) { + if let Some(tx) = marker.upgrade() { + finished(app, id, &tx, code, reason); + } +} + +async fn dial(host: &str, port: u16, tls: bool) -> Result, String> { + let addrs: Vec<_> = tokio::net::lookup_host((host, port)) + .await + .map_err(|e| format!("cannot resolve {host}: {e}"))? + .collect(); + let mut last = format!("{host} has no address"); + let mut tcp = None; + + // One address at a time, each with its own timeout: a dead IPv6 route + // must not use up the whole wait before IPv4 is tried. + for addr in addrs { + match tokio::time::timeout(ADDRESS_TIMEOUT, TcpStream::connect(addr)).await { + Ok(Ok(stream)) => { + tcp = Some(stream); + break; + } + Ok(Err(e)) => last = e.to_string(), + Err(_) => last = format!("{addr} timed out"), + } + } + + let tcp = tcp.ok_or(last)?; + let _ = tcp.set_nodelay(true); + + if !tls { + return Ok(Box::new(tcp)); + } + + let name = rustls::pki_types::ServerName::try_from(host.to_owned()) + .map_err(|_| format!("{host} is not a valid TLS server name"))?; + let stream = tls_connector()? + .connect(name, tcp) + .await + .map_err(|e| e.to_string())?; + Ok(Box::new(stream)) +} + +fn tls_connector() -> Result { + let roots = rustls::RootCertStore { + roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(), + }; + let config = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .map_err(|e| e.to_string())? + .with_root_certificates(roots) + .with_no_client_auth(); + Ok(tokio_rustls::TlsConnector::from(Arc::new(config))) +} + +/// `[::1]` → `::1`; anything else as given, trimmed. +pub fn clean_host(host: &str) -> String { + let host = host.trim(); + host.strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host) + .to_owned() +} + +pub fn port_allowed(port: u16) -> bool { + !BAD_PORTS.contains(&port) +} + +pub fn check_send_line(line: &str) -> Result<(), &'static str> { + if line.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) { + Err("a line may not hold CR, LF or NUL") + } else if line.len() > MAX_SEND_LINE { + Err("line too long") + } else { + Ok(()) + } +} + +/// The complete lines in `buf` (LF-terminated, a trailing CR stripped, empty +/// ones dropped), decoded; the unterminated rest stays in `buf`. A line over +/// MAX_LINE, finished or not, is an error. +pub fn take_lines(buf: &mut Vec) -> Result, &'static str> { + let mut lines = Vec::new(); + let mut start = 0; + + while let Some(at) = buf[start..].iter().position(|&b| b == b'\n') { + let end = start + at; + let mut line = &buf[start..end]; + + if let [rest @ .., b'\r'] = line { + line = rest; + } + + if line.len() > MAX_LINE { + return Err("line too long"); + } + + if !line.is_empty() { + lines.push(decode(line)); + } + + start = end + 1; + } + + buf.drain(..start); + + // The rest may still end in the CR of a CRLF split across reads. + if buf.len() > MAX_LINE + 1 { + return Err("line too long"); + } + + Ok(lines) +} + +/// UTF-8, or Latin-1 for a line that is not (older networks and clients). +pub fn decode(bytes: &[u8]) -> String { + match std::str::from_utf8(bytes) { + Ok(text) => text.to_owned(), + Err(_) => bytes.iter().map(|&b| b as char).collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_crlf_and_lf_and_keeps_the_rest() { + let mut buf = b"PING :a\r\nNOTICE x :y\n\r\n:part".to_vec(); + assert_eq!( + take_lines(&mut buf).unwrap(), + vec!["PING :a", "NOTICE x :y"] + ); + assert_eq!(buf, b":part"); + buf.extend_from_slice(b"ial\r\n"); + assert_eq!(take_lines(&mut buf).unwrap(), vec![":partial"]); + assert!(buf.is_empty()); + } + + #[test] + fn refuses_overlong_lines_finished_or_not() { + let mut long = vec![b'a'; MAX_LINE + 2]; + assert!(take_lines(&mut long.clone()).is_err()); + long.truncate(MAX_LINE + 1); + long.push(b'\n'); + assert!(take_lines(&mut long).is_err()); + let mut ok = vec![b'a'; MAX_LINE]; + ok.extend_from_slice(b"\r\n"); + assert_eq!(take_lines(&mut ok).unwrap()[0].len(), MAX_LINE); + } + + #[test] + fn decodes_utf8_or_latin1() { + assert_eq!(decode("café".as_bytes()), "café"); + assert_eq!(decode(b"caf\xe9"), "café"); + assert_eq!(decode(b"\x01ACTION waves\x01"), "\u{1}ACTION waves\u{1}"); + } + + #[test] + fn checks_sent_lines() { + assert!(check_send_line("PRIVMSG #a :hi").is_ok()); + assert!(check_send_line("PRIVMSG #a :hi\r\nQUIT").is_err()); + assert!(check_send_line("a\0b").is_err()); + assert!(check_send_line(&"a".repeat(MAX_SEND_LINE + 1)).is_err()); + } + + #[test] + fn allows_irc_ports_and_refuses_bad_ones() { + for port in [6665, 6667, 6669, 6679, 6697, 7000, 994, 443, 8067] { + assert!(port_allowed(port), "{port}"); + } + + for port in [0, 22, 25, 110, 143, 587, 993] { + assert!(!port_allowed(port), "{port}"); + } + } + + #[test] + fn strips_ipv6_brackets() { + assert_eq!(clean_host(" [::1] "), "::1"); + assert_eq!(clean_host("irc.example.org"), "irc.example.org"); + } + + #[tokio::test] + async fn dials_plain_tcp_and_frames_lines() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + sock.write_all(b":srv 001 me :hi\r\n").await.unwrap(); + let mut got = vec![0u8; 64]; + let n = sock.read(&mut got).await.unwrap(); + got.truncate(n); + got + }); + let mut stream = dial("127.0.0.1", port, false).await.unwrap(); + let mut buf = Vec::new(); + let mut chunk = [0u8; 64]; + let n = stream.read(&mut chunk).await.unwrap(); + buf.extend_from_slice(&chunk[..n]); + assert_eq!(take_lines(&mut buf).unwrap(), vec![":srv 001 me :hi"]); + stream.write_all(b"NICK me\r\n").await.unwrap(); + assert_eq!(server.await.unwrap(), b"NICK me\r\n"); + } + + #[tokio::test] + async fn reports_a_refused_dial() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + assert!(dial("127.0.0.1", port, false).await.is_err()); + } + + #[test] + fn tls_config_builds_with_ring() { + assert!(tls_connector().is_ok()); + } +} diff --git a/test/irc/client.ts b/test/irc/client.ts index 49e7dc972..b0ffdc208 100644 --- a/test/irc/client.ts +++ b/test/irc/client.ts @@ -1,13 +1,19 @@ import {expect} from "chai"; import sinon from "sinon"; import socket from "../../client/js/socket"; -import {IrcClient, IrcClientOptions, parseJoinList, buildUrl} from "../../client/js/irc/client"; +import { + IrcClient, + IrcClientOptions, + parseJoinList, + buildUrl, + tcpHostOf, +} from "../../client/js/irc/client"; import {IdAllocator} from "../../client/js/irc/ids"; import * as saved from "../../client/js/irc/saved-networks"; import {registerBusHandlers} from "../../client/js/irc/bus"; import {utf8ByteLength} from "../../client/js/irc/message"; import type {Transport} from "../../client/js/irc/types"; -import type {TransportEvent, TransportState} from "../../client/js/irc/transport"; +import type {TransportEvent, TransportOptions, TransportState} from "../../client/js/irc/transport"; import {CTCP_REPLY_INTERVAL_MS} from "../../client/js/irc/handlers/privmsg"; import {ChanState, ChanType} from "../../shared/types/chan"; import {MessageType, SharedMsg} from "../../shared/types/msg"; @@ -1204,6 +1210,46 @@ describe("IrcClient", function () { expect(payloads("init")).to.have.length(2); }); + it("refuses IRC over TCP without the app shell, and does not dial", function () { + const h = setup({transport: "tcp", port: 6697}); + h.client.connect(); + expect(h.transport.connectCalls).to.equal(0); + expect(messages().some((m) => /works only in the app/.test(m.text ?? ""))).to.equal( + true + ); + }); + + it("gives IRC over TCP the shell's socket, and rebuilds the transport when the type changes", function () { + const seen: TransportOptions[] = []; + const bridge = {call: () => Promise.resolve({}), listen() {}}; + const h = setup({ + transport: "tcp", + port: 6697, + socketBridge: bridge, + transportFactory(opts) { + seen.push(opts); + return new FakeTransport(); + }, + }); + expect(seen[0].url).to.equal("ircs://irc.test:6697"); + expect(seen[0].WebSocketImpl).to.be.a("function"); + + // Only the type changes: that alone needs a new transport. + h.client.applySettings({ + host: "irc.test", + port: 6697, + tls: true, + nick: "alice", + join: "#seance", + sasl: "", + saslAccount: "", + saslPassword: "", + }); + expect(seen).to.have.length(2); + expect(seen[1].url).to.equal("wss://irc.test:6697/"); + expect(seen[1].WebSocketImpl).to.equal(undefined); + }); + it("tears down at once when new settings swap the transport right after disconnect()", function () { const made: FakeTransport[] = []; const h = setup({ @@ -1484,6 +1530,15 @@ describe("IrcClient", function () { expect(buildUrl("::1", 8443, true)).to.equal("wss://[::1]:8443/"); }); + it("builds irc:// and ircs:// URLs for IRC over TCP, and bare hosts to dial", function () { + expect(buildUrl("irc.example.org/ws", 6697, true, "tcp")).to.equal( + "ircs://irc.example.org:6697" + ); + expect(buildUrl("::1", 6667, false, "tcp")).to.equal("irc://[::1]:6667"); + expect(tcpHostOf(" ircs://irc.example.org/x ")).to.equal("irc.example.org"); + expect(tcpHostOf("[::1]")).to.equal("::1"); + }); + it("parses the join list with keys and missing prefixes", function () { expect(parseJoinList("#a key, b ,&c, #A")).to.deep.equal([ {name: "#a", key: "key"}, diff --git a/test/irc/disconnect.ts b/test/irc/disconnect.ts index 21d2cf872..786a86fbd 100644 --- a/test/irc/disconnect.ts +++ b/test/irc/disconnect.ts @@ -35,6 +35,31 @@ describe("describeClose", function () { expect(report.hint).to.contain("open https://irc.example.org:8443/ in a new tab"); }); + it("gives IRC over TCP its own hints, with no browser advice", function () { + const tls = describeClose({ + ...base, + url: "ircs://irc.example.org:6697", + host: "irc.example.org", + phase: "connecting", + pageProtocol: "https:", + errorMessage: "invalid peer certificate: UnknownIssuer", + }); + + expect(tls.text).to.contain("(invalid peer certificate: UnknownIssuer)"); + expect(tls.hint).to.contain("self-signed"); + expect(tls.hint).to.not.contain("new tab"); + + const plain = describeClose({ + ...base, + url: "irc://irc.example.org:6667", + phase: "connecting", + pageProtocol: "https:", + }); + + expect(plain.hint).to.contain("blocks the connection"); + expect(plain.hint).to.not.contain("ws://"); + }); + it("includes an informative transport error but not the browser stock one", function () { const informative = describeClose({ ...base, diff --git a/test/irc/saved-networks.ts b/test/irc/saved-networks.ts index 3dccd97d7..1ea121e21 100644 --- a/test/irc/saved-networks.ts +++ b/test/irc/saved-networks.ts @@ -78,6 +78,37 @@ describe("saved-networks", function () { expect(saved.list()).to.deep.equal([]); }); + it("keeps IRC over TCP through save, edit and connect options", function () { + saved.save({...entry(), transport: "tcp", port: 6697}); + expect(saved.get(entry().uuid)?.transport).to.equal("tcp"); + expect(saved.toConnectOptions(saved.get(entry().uuid)!)).to.include({ + transport: "tcp", + port: 6697, + }); + + // The form sends `websocket` to go back; the stored `tcp` must not survive the merge. + const back = saved.fromForm( + {uuid: entry().uuid, transport: "websocket", tls: "on"}, + saved.get(entry().uuid) + ); + expect(back).to.not.have.property("transport"); + expect(saved.toConnectOptions(back)).to.not.have.property("transport"); + + // Anything but `tcp` is the WebSocket default. + expect(saved.normalize({...entry(), transport: "carrier pigeon"})).to.not.have.property( + "transport" + ); + }); + + it("has TCP default ports beside the WebSocket ones", function () { + expect(saved.defaultPort(true, "tcp")).to.equal(6697); + expect(saved.defaultPort(false, "tcp")).to.equal(6667); + expect(saved.defaultPort(true)).to.equal(9998); + expect( + saved.normalize({...entry(), transport: "tcp", tls: false, port: ""})?.port + ).to.equal(6667); + }); + it("saves and reads back an entry under thelounge.networks", function () { saved.save(entry()); diff --git a/test/irc/shellSocket.ts b/test/irc/shellSocket.ts new file mode 100644 index 000000000..2862fde82 --- /dev/null +++ b/test/irc/shellSocket.ts @@ -0,0 +1,279 @@ +import {expect} from "chai"; +import {ShellSocket, SocketBridge, shellSocketCtor} from "../../client/js/irc/shellSocket"; +import {WsTransport, TransportEvent} from "../../client/js/irc/transport"; + +/** A stand-in for `window.seanceShell`'s Socket plugin, driven by the tests. */ +class FakeBridge implements SocketBridge { + calls: {method: string; options: any}[] = []; + /** Methods that reject, with this message. */ + refuse = new Map(); + /** While set, calls wait until release() — to show they still run in order. */ + private gate: Promise | null = null; + private open: (() => void) | null = null; + private listeners = new Map void>(); + + hold(): void { + this.gate = new Promise((resolve) => (this.open = resolve)); + } + + release(): void { + this.open?.(); + this.gate = null; + } + + async call(plugin: string, method: string, options?: unknown): Promise { + expect(plugin).to.equal("Socket"); + const gate = this.gate; + + if (gate && method === "open") { + await gate; + } + + this.calls.push({method, options}); + const refusal = this.refuse.get(method); + + if (refusal) { + throw Object.assign(new Error(refusal), {code: "refused"}); + } + + return {}; + } + + listen(plugin: string, event: string, cb: (data: any) => void): void { + expect(plugin).to.equal("Socket"); + this.listeners.set(event, cb); + } + + emit(event: string, data: unknown): void { + this.listeners.get(event)?.(data); + } + + methods(): string[] { + return this.calls.map((c) => c.method); + } +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function record(socket: ShellSocket): string[] { + const log: string[] = []; + socket.addEventListener("open", () => log.push(`open:${socket.readyState}`)); + socket.addEventListener("message", (ev) => log.push(`line:${String(ev.data)}`)); + socket.addEventListener("error", (ev) => + log.push(`error:${(ev as {message: string}).message}`) + ); + socket.addEventListener("close", (ev) => + log.push(`close:${ev.code}:${ev.reason}:${ev.wasClean}`) + ); + return log; +} + +const endpoint = {host: "irc.example.org", port: 6697, tls: true}; + +describe("ShellSocket", function () { + it("opens, delivers lines and sends in one call per tick", async function () { + const bridge = new FakeBridge(); + const socket = new ShellSocket(bridge, endpoint, "a"); + const log = record(socket); + await tick(); + expect(bridge.calls[0]).to.deep.equal({ + method: "open", + options: {id: "a", host: "irc.example.org", port: 6697, tls: true}, + }); + expect(socket.readyState).to.equal(socket.CONNECTING); + + bridge.emit("open", {id: "a"}); + bridge.emit("lines", {id: "a", lines: [":srv 001 me :hi", ":srv 002 me :x"]}); + expect(log).to.deep.equal(["open:1", "line::srv 001 me :hi", "line::srv 002 me :x"]); + + socket.send("NICK me"); + socket.send("USER me 0 * :me"); + await tick(); + expect(bridge.calls[1]).to.deep.equal({ + method: "send", + options: {id: "a", lines: ["NICK me", "USER me 0 * :me"]}, + }); + }); + + it("throws on send before it is open", function () { + const socket = new ShellSocket(new FakeBridge(), endpoint, "b"); + expect(() => socket.send("NICK me")).to.throw(/not open/); + }); + + it("keeps sends behind a slow open and a close behind the sends", async function () { + const bridge = new FakeBridge(); + bridge.hold(); + const socket = new ShellSocket(bridge, endpoint, "c"); + // The shell's open event can come before its call resolves. + bridge.emit("open", {id: "c"}); + socket.send("QUIT :bye"); + socket.close(); + bridge.release(); + await tick(); + expect(bridge.methods()).to.deep.equal(["open", "send", "close"]); + expect(bridge.calls[1].options.lines).to.deep.equal(["QUIT :bye"]); + }); + + it("fires its own close once when the page closes it", async function () { + const bridge = new FakeBridge(); + const socket = new ShellSocket(bridge, endpoint, "d"); + const log = record(socket); + bridge.emit("open", {id: "d"}); + socket.close(); + socket.close(); + expect(socket.readyState).to.equal(socket.CLOSING); + await tick(); + // A late close from the shell, or more lines, change nothing. + bridge.emit("lines", {id: "d", lines: ["late"]}); + bridge.emit("close", {id: "d", code: 1006, reason: "reset"}); + expect(log).to.deep.equal(["open:1", "close:1000::true"]); + expect(socket.readyState).to.equal(socket.CLOSED); + }); + + it("closes with 1006 when the page closes it while connecting, and ignores a late open", async function () { + const bridge = new FakeBridge(); + const socket = new ShellSocket(bridge, endpoint, "e"); + const log = record(socket); + socket.close(); + await tick(); + bridge.emit("open", {id: "e"}); + expect(log).to.deep.equal(["close:1006:closed while connecting:false"]); + }); + + it("reports a lost socket as error then close, with the shell's reason", function () { + const bridge = new FakeBridge(); + const socket = new ShellSocket(bridge, endpoint, "f"); + const log = record(socket); + bridge.emit("open", {id: "f"}); + bridge.emit("close", {id: "f", code: 1006, reason: "connection reset"}); + bridge.emit("close", {id: "f", code: 1006, reason: "again"}); + expect(log).to.deep.equal([ + "open:1", + "error:connection reset", + "close:1006:connection reset:false", + ]); + }); + + it("reports an orderly EOF as a clean close, with no error", function () { + const bridge = new FakeBridge(); + const socket = new ShellSocket(bridge, endpoint, "g"); + const log = record(socket); + bridge.emit("open", {id: "g"}); + bridge.emit("close", {id: "g", code: 1000, reason: ""}); + expect(log).to.deep.equal(["open:1", "close:1000::true"]); + }); + + it("turns a refused open into error then close", async function () { + const bridge = new FakeBridge(); + bridge.refuse.set("open", "port 25 is not allowed"); + const socket = new ShellSocket(bridge, endpoint, "h"); + const log = record(socket); + await tick(); + expect(log).to.deep.equal([ + "error:port 25 is not allowed", + "close:1006:port 25 is not allowed:false", + ]); + }); + + it("turns a refused send into error then close", async function () { + const bridge = new FakeBridge(); + bridge.refuse.set("send", "queue full"); + const socket = new ShellSocket(bridge, endpoint, "i"); + const log = record(socket); + bridge.emit("open", {id: "i"}); + socket.send("PRIVMSG #a :x"); + await tick(); + expect(log).to.deep.equal(["open:1", "error:queue full", "close:1006:queue full:false"]); + }); + + it("routes events by id and ignores unknown ids", function () { + const bridge = new FakeBridge(); + const one = new ShellSocket(bridge, endpoint, "one"); + const two = new ShellSocket(bridge, endpoint, "two"); + const logOne = record(one); + const logTwo = record(two); + bridge.emit("open", {id: "two"}); + bridge.emit("open", {id: "nobody"}); + bridge.emit("lines", {lines: ["no id"]}); + expect(logOne).to.deep.equal([]); + expect(logTwo).to.deep.equal(["open:1"]); + }); + + it("makes random ids when none is given", function () { + const bridge = new FakeBridge(); + const a = new ShellSocket(bridge, endpoint); + const b = new ShellSocket(bridge, endpoint); + expect(a.id).to.match(/^[0-9a-f-]{36}$/); + expect(a.id).to.not.equal(b.id); + }); + + describe("under WsTransport", function () { + it("registers, answers PING and closes on request", async function () { + const bridge = new FakeBridge(); + const transport = new WsTransport({ + url: "ircs://irc.example.org:6697", + WebSocketImpl: shellSocketCtor(bridge, endpoint), + reconnect: { + enabled: false, + initialDelayMs: 1, + maxDelayMs: 1, + factor: 1, + jitter: false, + }, + }); + const events: TransportEvent[] = []; + transport.on((ev) => events.push(ev)); + transport.connect(); + await tick(); + const id = bridge.calls[0].options.id as string; + expect(bridge.calls[0].options.host).to.equal("irc.example.org"); + + bridge.emit("open", {id}); + expect(transport.state).to.equal("open"); + transport.send("CAP LS 302"); + bridge.emit("lines", {id, lines: ["PING :abc"]}); + await tick(); + // The PONG and the line sent before it go out in order. + expect(bridge.calls[1].options.lines).to.deep.equal(["CAP LS 302", "PONG :abc"]); + + transport.close(); + await tick(); + expect(bridge.methods()).to.deep.equal(["open", "send", "close"]); + expect(transport.state).to.equal("closed"); + expect(events.map((e) => e.type)).to.include.members(["open", "line", "close"]); + const close = events.find((e) => e.type === "close") as {willReconnect: boolean}; + expect(close.willReconnect).to.equal(false); + }); + + it("abandons a dial: the old socket is closed in the shell, its late open ignored", async function () { + const bridge = new FakeBridge(); + const transport = new WsTransport({ + url: "irc://irc.example.org:6667", + WebSocketImpl: shellSocketCtor(bridge, {...endpoint, port: 6667, tls: false}), + reconnect: { + enabled: false, + initialDelayMs: 1, + maxDelayMs: 1, + factor: 1, + jitter: false, + }, + }); + transport.connect(); + await tick(); + const first = bridge.calls[0].options.id as string; + transport.redial(); + await tick(); + const opens = bridge.calls.filter((c) => c.method === "open"); + expect(opens).to.have.length(2); + expect( + bridge.calls.some((c) => c.method === "close" && c.options.id === first) + ).to.equal(true); + bridge.emit("open", {id: first}); + expect(transport.state).to.equal("connecting"); + bridge.emit("open", {id: opens[1].options.id}); + expect(transport.state).to.equal("open"); + transport.close(); + await tick(); + }); + }); +}); diff --git a/test/irc/sts.ts b/test/irc/sts.ts index 82773fd09..952426e99 100644 --- a/test/irc/sts.ts +++ b/test/irc/sts.ts @@ -256,6 +256,26 @@ describe("STS", function () { }); }); + describe("transport", function () { + it("keeps TCP and WebSocket policies for one host apart", function () { + expect(sts.stsHost({host: "irc.example.org"})).to.equal("irc.example.org"); + expect(sts.stsHost({host: "irc.example.org/ws", transport: "tcp"})).to.equal( + "tcp:irc.example.org" + ); + sts.setPolicy("irc.example.org", {port: 8443, expiresAt: T0 + 1000, preload: false}); + const tcp = {...BASE, transport: "tcp" as const, port: 6667}; + expect(sts.upgradeOptions(tcp)).to.equal(tcp); + + sts.setPolicy("tcp:irc.example.org", { + port: 6697, + expiresAt: T0 + 1000, + preload: false, + }); + expect(sts.upgradeOptions(tcp)).to.deep.equal({...tcp, tls: true, port: 6697}); + expect(sts.upgradeOptions(BASE)).to.deep.equal({...BASE, tls: true, port: 8443}); + }); + }); + describe("IrcClient", function () { it("on an insecure connection, sts port= reconnects securely exactly once", function () { const h = setup(); From 38a501c16ec530e711c379f145482171486dea05 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 16:11:24 +0000 Subject: [PATCH 2/6] IRC over TCP: fixes from the review and the first run in the app 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. --- client/js/irc/manager.ts | 12 +- client/js/irc/saved-networks.ts | 35 +- client/js/irc/shellSocket.ts | 36 +- docs/resources/client-features.md | 4 + shells/tauri/src-tauri/src/lib.rs | 2 +- shells/tauri/src-tauri/src/socket.rs | 491 ++++++++++++++++++++------- test/irc/saved-networks.ts | 16 + test/irc/shellSocket.ts | 57 +++- 8 files changed, 510 insertions(+), 143 deletions(-) diff --git a/client/js/irc/manager.ts b/client/js/irc/manager.ts index 3edd78a47..39b2af899 100644 --- a/client/js/irc/manager.ts +++ b/client/js/irc/manager.ts @@ -74,18 +74,10 @@ function allNetworks() { function rememberNetwork(options: CreateNetworkOptions): SavedNetwork { const uuid = options.uuid || - saved.findMatching(options.host, options.port, options.nick)?.uuid || + saved.findMatching(options.host, options.port, options.nick, options.transport)?.uuid || saved.newUuid(); const existing = saved.get(uuid); - const merged: Record = {...(existing ?? {}), uuid}; - - for (const [key, value] of Object.entries(options)) { - if (value !== undefined) { - merged[key] = value; - } - } - - const entry = saved.normalize(merged); + const entry = saved.normalize(saved.mergeOptions(existing, uuid, options)); if (!entry) { throw new Error("createNetwork: missing uuid"); diff --git a/client/js/irc/saved-networks.ts b/client/js/irc/saved-networks.ts index 45b28f626..67f94b28b 100644 --- a/client/js/irc/saved-networks.ts +++ b/client/js/irc/saved-networks.ts @@ -576,7 +576,12 @@ export function lastUsed(): SavedNetwork | undefined { * An existing entry for the same server and nick, so re-typing a network * into the connect form keeps its uuid (and the preferences keyed by it). */ -export function findMatching(host: string, port: number, nick: string): SavedNetwork | undefined { +export function findMatching( + host: string, + port: number, + nick: string, + transport?: ConnectOptions["transport"] +): SavedNetwork | undefined { const h = hostnameOf(host).toLowerCase(); const n = nick.trim().toLowerCase(); @@ -584,6 +589,32 @@ export function findMatching(host: string, port: number, nick: string): SavedNet (net) => hostnameOf(net.host).toLowerCase() === h && net.port === port && - net.nick.toLowerCase() === n + net.nick.toLowerCase() === n && + net.transport === transport ); } + +/** + * The stored entry with the connect options laid over it (undefined options + * keep the stored value). The connection type is the exception: options + * without `transport: "tcp"` are a WebSocket connection, whatever was stored. + */ +export function mergeOptions( + existing: SavedNetwork | undefined, + uuid: string, + options: Partial & Record +): Record { + const merged: Record = {...(existing ?? {}), uuid}; + + for (const [key, value] of Object.entries(options)) { + if (value !== undefined) { + merged[key] = value; + } + } + + if (options.transport !== "tcp") { + delete merged.transport; + } + + return merged; +} diff --git a/client/js/irc/shellSocket.ts b/client/js/irc/shellSocket.ts index fd924b027..4b894a0d1 100644 --- a/client/js/irc/shellSocket.ts +++ b/client/js/irc/shellSocket.ts @@ -80,6 +80,12 @@ function newId(): string { return globalThis.crypto.randomUUID(); } +/** + * This page load's token, sent with every open: after a reload the shell + * refuses an open from the page before, which can still be in flight. + */ +const PAGE = newId(); + export class ShellSocket implements LineSocket { readonly CONNECTING = 0; readonly OPEN = 1; @@ -106,9 +112,23 @@ export class ShellSocket implements LineSocket { this.id = id; this.sockets = routesFor(bridge); this.sockets.set(id, this); - this.chain = bridge - .call(PLUGIN, "open", {id, host: endpoint.host, port: endpoint.port, tls: endpoint.tls}) - .catch((err: unknown) => this.onShellClose(1006, messageOf(err))); + const options = { + id, + page: PAGE, + host: endpoint.host, + port: endpoint.port, + tls: endpoint.tls, + }; + let opened: Promise; + + try { + opened = bridge.call(PLUGIN, "open", options); + } catch (err: unknown) { + // A bridge that throws at once fails like one that rejects. + opened = Promise.reject(err instanceof Error ? err : new Error(messageOf(err))); + } + + this.chain = opened.catch((err: unknown) => this.onShellClose(1006, messageOf(err))); } get readyState(): number { @@ -222,7 +242,15 @@ export class ShellSocket implements LineSocket { private fire(type: K, ev: unknown): void { for (const listener of [...this.listeners[type]]) { - listener(ev); + // One listener's throw must not cost the rest of a read's lines; + // it is rethrown on its own so it is still reported. + try { + listener(ev); + } catch (err: unknown) { + queueMicrotask(() => { + throw err; + }); + } } } } diff --git a/docs/resources/client-features.md b/docs/resources/client-features.md index bff9bc475..0b436f58e 100644 --- a/docs/resources/client-features.md +++ b/docs/resources/client-features.md @@ -108,6 +108,10 @@ How the parts of the client behave and why, moved out of `CLAUDE.md` on 2026-11- **Lua scripting** (`docs/resources/lua-scripting.md`; the user's guide to writing scripts, every example run against the sandbox, is `docs/areas/scripting/`) — off unless Settings → Scripts → Enable scripting (`irc/scripting/enabled.ts`; while off the Fengari chunk is never fetched). `irc/scripting/engine.ts` (`LuaSandbox`) opens only base/coroutine/table/string/math/utf8 (never `luaL_openlibs`: its `require` brought `io`/`os`/`debug` back), text-only `load`, no `string.dump`, a capped `string.rep`, a 100k-instruction hook that coroutines inherit and that a `pcall` loop cannot outlast, and one environment per script with a read-only `seance` bound to its id (storage and errors credited to the script that ran), its own read-only views of the standard library, its own `_G` and `load`, and a locked string metatable; every handler gets its own copy of the event; each handler and command runs through the host's `run` (a fresh budget for it and for the loop after it; `MAX_EVENT_INSTRUCTIONS` per event; at most `MAX_HANDLERS` per event and `MAX_COMMANDS`; an event runs the handlers it started with); a failing script command is never sent raw; the dispatch tables live in the bootstrap chunk's locals; pattern matching has a step budget (200k `match` calls per call, a `gmatch` step on its own) patched into `fengari/src/lstrlib.js` at build time by `scripts/fengari-match-budget.js` (a transform in `vite.config.mts`), which fails the build if Fengari's source moved — mocha loads Fengari unpatched, so `test/scripts/fengari-match-budget.ts` runs the patched source and `test/tests/build.ts` checks the chunk; JS exceptions in the VM become Lua errors (`lua_atnativeerror`) and error objects are described without `__tostring`, since either used to leave the state dead for every script. Every event carries `networkUuid`, which `seance.reply`/`seance.send` route by (never by name, never falling back to another network); the manager's `generation` keeps a sandbox that loaded after a switch-off or a newer reload from being installed; "Check syntax" only compiles (`checkSyntax`). `irc/scripting/manager.ts` routes events (the handlers call `scriptManager.dispatch*`, never during a replay) and sends: a typed command's lines at once, an event handler's through `IrcClient.background` (at most `MAX_QUEUED_LINES` waiting), and a script's own echo never wakes its `message` handlers. `irc/scripting/storage.ts` caps the source (16k characters each, 256k the library) and `seance.storage` (16k per script, 128k together) and reads every write back (`ScriptStorageError`). A settings restore brings scripts and the switch back off (`settingsBackup.ts` `disarmScripts`). UI: the Settings → Scripts tab (`Settings/Scripts.vue`, `/settings/scripts`: the switch and the library) and its editor (`Settings/ScriptEdit.vue`, `/settings/scripts/`, a child route: an overlay one layer above the settings modal, z-index 30 in `#viewport`, wider than it, closed by Done/✕/Escape back to the list; the code is CodeMirror 6 with the Lua mode, `helpers/luaEditor.ts`, its own lazy chunk `js/code-editor..js`, tokens as the `tok-*` classes the chat's Prism blocks use so every theme's `--tok-*` palette applies, a plain textarea until the chunk loads), helpers in `irc/scripting/library.ts`; no sidebar icon. `/lua` runs a snippet. Tests `test/irc/scripting.ts`; browser checks `tools/scenarios/scripting-editor.mjs`, `test-lua-live.mjs` (two bots, every event a script can take, in `SEANCE_IRC_CHANNEL`, default `#seance-testing`, which must be empty: the first bot takes ops to kick, voice and set the topic; `SEANCE_IRC_URL`). +## IRC over TCP (the app only) + +A network can be reached over plain TCP or TLS instead of WebSocket, in the app shell only: a browser cannot open a raw socket (Direct Sockets is for Isolated Web Apps on managed ChromeOS). `ConnectOptions.transport: "tcp"` (absent = WebSocket; default ports 6667/6697, `saved-networks.ts` `defaultPort`) is set by the "Connection" select in `Connect.vue` and `NetworkForm.vue`, shown only inside a shell; `mergeOptions` lets the connect form switch a saved TCP network back, and `findMatching` keys on it. `WsTransport` keeps its whole policy and takes the socket through `TransportOptions.WebSocketImpl`, typed as `LineSocket` (the part of `WebSocket` it uses): `client.ts` `createTransport` hands it `shellSocket.ts` `ShellSocket`, bound to the endpoint, which calls the shell's `Socket` plugin (`shells/tauri/src-tauri/src/socket.rs`) through `IrcClientOptions.socketBridge` (`manager.ts` passes `window.seanceShell`). ShellSocket keeps one promise chain of shell calls per socket (open, the lines of a tick as one send, close), fires its own close on `close()`, and error-then-close on a failure with the shell's reason; a page token with every open lets the shell refuse an open from the page before a reload. Without a bridge (a browser) a TCP network refuses to connect (`connect.tcpNeedsApp`). The shell does framing (CRLF out; lines split, 16 KiB cap, UTF-8 else Latin-1 in), TLS (rustls on ring, Mozilla's roots from `webpki-roots`; no pinning, so a self-signed server is reachable without TLS only) and the limits a page-reachable raw socket needs: the Fetch spec's bad ports less the IRC ones, no empty line or CR/LF/NUL, only registration commands until the server's first line looks like IRC (else the socket closes), a 512-line queue, 10 s per write, 16 sockets; a page load closes them after a 1 s grace for the `pagehide` QUIT. STS policies are kept per transport (`sts.ts` `stsHost`: `tcp:host`), since one server's WebSocket and IRC TLS ports differ; a WebSocket policy does not upgrade a first plain-TCP dial. `disconnect.ts` gives TCP its own hints. Tests `test/irc/shellSocket.ts` (also under `WsTransport`), the `transport` cases in `test/irc/{client,saved-networks,sts,disconnect}.ts`, and `cargo test` in `shells/tauri/src-tauri` (framing, decoding, the send and first-line gates, ports, a dial to a local socket). + ## Translation On-device translation of what a channel reads and of the composer's drafts. The long form is `docs/resources/translation.md` (engines, router, worker, weights, devices, licences) and the design `docs/projects/client-translation.md`; the per-device choice is `docs/projects/translation-devices.md`. diff --git a/shells/tauri/src-tauri/src/lib.rs b/shells/tauri/src-tauri/src/lib.rs index 70c6fcd02..744088694 100644 --- a/shells/tauri/src-tauri/src/lib.rs +++ b/shells/tauri/src-tauri/src/lib.rs @@ -409,7 +409,7 @@ fn create_main(app: &AppHandle) -> tauri::Result { if started { handle.state::>().lock().unwrap().listening = false; // The old page's connections go with it. - handle.state::().close_all(); + handle.state::().close_all(&handle); } let waiting = { diff --git a/shells/tauri/src-tauri/src/socket.rs b/shells/tauri/src-tauri/src/socket.rs index 2b13eeb22..55d1996fe 100644 --- a/shells/tauri/src-tauri/src/socket.rs +++ b/shells/tauri/src-tauri/src/socket.rs @@ -4,20 +4,27 @@ // split and decoded in, TLS against Mozilla's roots (webpki-roots). // // Unlike the shell's other calls this does something a web page cannot, so -// every limit is enforced here, not trusted to the page: ports on the Fetch -// spec's bad-ports list (SMTP, POP, IMAP, …) are refused, a sent line may -// hold no CR, LF or NUL, lines and queues are capped, and there are at most -// MAX_SOCKETS at once. +// every limit is enforced here, not trusted to the page, to keep it an IRC +// client and not a general one for whatever listens on the LAN: +// - ports on the Fetch spec's bad-ports list (SMTP, POP, IMAP, …) are refused; +// - a sent line may not be empty or hold CR, LF or NUL; +// - until the server has said something that looks like IRC, only the +// registration commands may be sent, and a first line that does not look +// like IRC (an HTTP status, a Redis `-ERR`) closes the socket; +// - lines, queues and writes are capped; at most MAX_SOCKETS at once. // -// `Socket.open {id, host, port, tls}` → events `open {id}`, `lines {id, -// lines}` (one per read), `close {id, code, reason}`: 1000 when the server -// ended the connection, 1006 for any failure. `Socket.send {id, lines}`. -// `Socket.close {id}`: the queued lines are written (a QUIT gets out), the -// socket shut, and no event follows. A page load closes every socket. +// `Socket.open {id, page, host, port, tls}` → events `open {id}`, `lines +// {id, lines}` (one per read), `close {id, code, reason}`: 1000 when the +// server ended the connection, 1006 for any failure. `Socket.send {id, +// lines}`, once open. `Socket.close {id}`: the queued lines are written (a +// QUIT gets out), the socket shut, and no event follows. A page load closes +// every socket, and an open from the page before it (`page`, a token per +// page load) is refused. use serde_json::{json, Value}; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use tauri::{AppHandle, Manager}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; @@ -37,6 +44,20 @@ pub const MAX_LINE: usize = 16 * 1024; const ADDRESS_TIMEOUT: Duration = Duration::from_secs(5); const DIAL_DEADLINE: Duration = Duration::from_secs(20); const DRAIN_TIMEOUT: Duration = Duration::from_secs(2); +const PAGE_GONE_GRACE: Duration = Duration::from_secs(1); +/// One write; a server that stops reading must not hold the socket forever. +const WRITE_TIMEOUT: Duration = Duration::from_secs(10); +/// What may be sent before the server has said anything IRC-like. PING is +/// the page's liveness probe, QUIT a disconnect before any reply. +const REGISTRATION: &[&str] = &[ + "CAP", + "PASS", + "NICK", + "USER", + "AUTHENTICATE", + "PING", + "QUIT", +]; /// The Fetch spec's bad ports (https://fetch.spec.whatwg.org/#port-blocking), /// less the IRC ones on it (6665–6669, 6679, 6697). @@ -47,15 +68,60 @@ const BAD_PORTS: &[u16] = &[ 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 10080, ]; -/// The live sockets: each one's queue of lines to write. Dropping a sender -/// is how a socket is closed — its task drains what is queued and ends. +/// A live socket: its queue of lines to write — dropping the sender is how +/// a socket is closed, its task drains what is queued and ends — and what +/// its task has seen. +struct Entry { + tx: mpsc::Sender, + /// Connected (the page may send). + open: Arc, + /// The server's first line looked like IRC (any command may be sent). + greeted: Arc, +} + #[derive(Default)] -pub struct Sockets(Mutex>>); +struct State { + sockets: HashMap, + /// The token of the page load that is opening sockets. + page: Option, + /// Tokens of earlier page loads, whose late opens are refused. + retired: HashSet, + /// The page before a reload's sockets, for PAGE_GONE_GRACE: WebKit + /// starts the new load before the old page's `pagehide`, whose QUIT + /// must still get out. They say nothing more to the page. + retiring: HashMap, +} + +#[derive(Default)] +pub struct Sockets(Mutex); impl Sockets { - /// A page load: the old page's sockets go with it. - pub fn close_all(&self) { - self.0.lock().unwrap().clear(); + /// A page load: the old page's sockets go with it, after a grace for + /// the QUIT its `pagehide` sends. + pub fn close_all(&self, app: &AppHandle) { + let mut state = self.0.lock().unwrap(); + let old: Vec = state.sockets.keys().cloned().collect(); + let sockets = std::mem::take(&mut state.sockets); + state.retiring.extend(sockets); + + if let Some(page) = state.page.take() { + state.retired.insert(page); + } + + if old.is_empty() { + return; + } + + let app = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(PAGE_GONE_GRACE).await; + let sockets = app.state::(); + let mut state = sockets.0.lock().unwrap(); + + for id in old { + state.retiring.remove(&id); + } + }); } } @@ -79,6 +145,10 @@ pub fn call(app: &AppHandle, method: &str, options: &Value) -> Result { + let page = str_option(options, "page") + .filter(|p| !p.is_empty() && p.len() <= 64) + .ok_or_else(|| refuse("no page token"))? + .to_owned(); let host = str_option(options, "host") .map(clean_host) .unwrap_or_default(); @@ -94,25 +164,48 @@ pub fn call(app: &AppHandle, method: &str, options: &Value) -> Result= MAX_SOCKETS { + if state.sockets.len() >= MAX_SOCKETS { return Err(refuse("too many connections")); } // In the map before the task starts: a close that comes // while it dials finds it, and the dial is abandoned. - map.insert(id.clone(), tx.clone()); + state.sockets.insert( + id.clone(), + Entry { + tx: tx.clone(), + open: open.clone(), + greeted: greeted.clone(), + }, + ); } - let app = app.clone(); - tauri::async_runtime::spawn(run(app, id, host, port, tls, tx, rx)); + let task = Task { + app: app.clone(), + id, + marker: tx.downgrade(), + open, + greeted, + }; + drop(tx); + tauri::async_runtime::spawn(task.run(host, port, tls, rx)); Ok(json!({})) } "send" => { @@ -120,18 +213,32 @@ pub fn call(app: &AppHandle, method: &str, options: &Value) -> Result Result { - sockets.0.lock().unwrap().remove(&id); + let mut state = sockets.0.lock().unwrap(); + state.sockets.remove(&id); + state.retiring.remove(&id); Ok(json!({})) } _ => Err(ShellError { @@ -157,14 +266,13 @@ pub fn call(app: &AppHandle, method: &str, options: &Value) -> Result() - .0 - .lock() - .unwrap() - .remove(id) - .is_some() - { + let sockets = app.state::(); + let mut state = sockets.0.lock().unwrap(); + state.retiring.remove(id); // a page that is gone hears nothing + let live = state.sockets.remove(id).is_some(); + drop(state); + + if live { crate::emit( app, PLUGIN, @@ -179,11 +287,15 @@ fn drop_socket(app: &AppHandle, id: &str, code: u16, reason: &str) { /// unless the page closed it or a newer page owns the map. fn finished(app: &AppHandle, id: &str, tx: &mpsc::Sender, code: u16, reason: &str) { let sockets = app.state::(); - let mut map = sockets.0.lock().unwrap(); + let mut state = sockets.0.lock().unwrap(); - if map.get(id).is_some_and(|ours| ours.same_channel(tx)) { - map.remove(id); - drop(map); + if state + .sockets + .get(id) + .is_some_and(|ours| ours.tx.same_channel(tx)) + { + state.sockets.remove(id); + drop(state); crate::emit( app, PLUGIN, @@ -197,86 +309,122 @@ fn finished(app: &AppHandle, id: &str, tx: &mpsc::Sender, code: u16, rea trait Stream: AsyncRead + AsyncWrite + Send + Unpin {} impl Stream for T {} -async fn run( +/// One socket's task: dial, then move lines both ways until either side ends. +struct Task { app: AppHandle, id: String, - host: String, - port: u16, - tls: bool, - tx: mpsc::Sender, - mut rx: mpsc::Receiver, -) { - // Our own sender is held only to recognise our map entry; the page's - // close is the map dropping its copy, seen as `recv()` → None below - // once this one is gone too. - let marker = tx.downgrade(); - drop(tx); - - let dial = tokio::time::timeout(DIAL_DEADLINE, dial(&host, port, tls)); - let stream = tokio::select! { - result = dial => match result { - Ok(Ok(stream)) => stream, - Ok(Err(reason)) => return end(&app, &id, &marker, 1006, &reason), - Err(_) => return end(&app, &id, &marker, 1006, "connect timed out"), - }, - // Closed by the page while dialling: give up quietly. - None = rx.recv() => return, - }; - - crate::emit(&app, PLUGIN, "open", json!({ "id": id }), false); - let (mut reader, mut writer) = tokio::io::split(stream); - let mut buf: Vec = Vec::new(); - let mut chunk = vec![0u8; 16 * 1024]; - - loop { - tokio::select! { - read = reader.read(&mut chunk) => match read { - Ok(0) => return end(&app, &id, &marker, 1000, "the server closed the connection"), - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - - match take_lines(&mut buf) { - Ok(lines) if lines.is_empty() => {} - Ok(lines) => crate::emit(&app, PLUGIN, "lines", json!({"id": id, "lines": lines}), false), - Err(reason) => return end(&app, &id, &marker, 1006, reason), - } - } - // Most ircds drop the connection without TLS's close_notify. - Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { - return end(&app, &id, &marker, 1000, "the server closed the connection") - } - Err(e) => return end(&app, &id, &marker, 1006, &e.to_string()), + /// Recognises our map entry; it stops upgrading once the page closed us. + marker: mpsc::WeakSender, + open: Arc, + greeted: Arc, +} + +impl Task { + async fn run(self, host: String, port: u16, tls: bool, mut rx: mpsc::Receiver) { + let dial = tokio::time::timeout(DIAL_DEADLINE, dial(&host, port, tls)); + let stream = tokio::select! { + result = dial => match result { + Ok(Ok(stream)) => stream, + Ok(Err(reason)) => return self.end(1006, &reason), + Err(_) => return self.end(1006, "connect timed out"), }, - line = rx.recv() => match line { - Some(line) => { - let mut bytes = line.into_bytes(); - bytes.extend_from_slice(b"\r\n"); + // Closed by the page while dialling (`send` refuses lines until + // open, so nothing but the close can arrive): give up quietly. + _ = rx.recv() => return, + }; + + self.open.store(true, Ordering::SeqCst); + crate::emit(&self.app, PLUGIN, "open", json!({ "id": self.id }), false); + let (mut reader, mut writer) = tokio::io::split(stream); + let mut buf: Vec = Vec::new(); + let mut chunk = vec![0u8; 16 * 1024]; + + loop { + tokio::select! { + read = reader.read(&mut chunk) => match read { + Ok(0) => return self.end(1000, "the server closed the connection"), + Ok(n) => { + buf.extend_from_slice(&chunk[..n]); + + match take_lines(&mut buf) { + Ok(lines) if lines.is_empty() => {} + Ok(lines) => { + if !self.greeted.load(Ordering::SeqCst) { + if !looks_like_irc(&lines[0]) { + return self.end(1006, "the server does not speak IRC"); + } + + self.greeted.store(true, Ordering::SeqCst); + } + + crate::emit(&self.app, PLUGIN, "lines", json!({"id": self.id, "lines": lines}), false); + } + Err(reason) => return self.end(1006, reason), + } + } + // Most ircds drop the connection without TLS's close_notify. + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + return self.end(1000, "the server closed the connection") + } + Err(e) => return self.end(1006, &e.to_string()), + }, + line = rx.recv() => { + // Closed by the page (or a page load) once our marker is + // dead: whatever is still queued (a QUIT) gets the drain's + // time, all of it, then the socket is shut, quietly. + let Some(line) = line else { + return drain(&mut writer, Vec::new()).await; + }; + + if self.marker.upgrade().is_none() { + let mut rest = vec![line]; + + while let Ok(more) = rx.try_recv() { + rest.push(more); + } + + return drain(&mut writer, rest).await; + } - if let Err(e) = writer.write_all(&bytes).await { - return end(&app, &id, &marker, 1006, &e.to_string()); + match tokio::time::timeout(WRITE_TIMEOUT, write_line(&mut writer, line)).await { + Ok(Ok(())) => {} + Ok(Err(e)) => return self.end(1006, &e.to_string()), + Err(_) => return self.end(1006, "the server stopped reading"), } } - // Closed by the page (or a page load): everything queued has - // been written by now; flush, shut down, quietly. - None => { - let _ = tokio::time::timeout(DRAIN_TIMEOUT, async { - let _ = writer.flush().await; - let _ = writer.shutdown().await; - }) - .await; - return; - } - }, + } } } -} -fn end(app: &AppHandle, id: &str, marker: &mpsc::WeakSender, code: u16, reason: &str) { - if let Some(tx) = marker.upgrade() { - finished(app, id, &tx, code, reason); + /// The socket ended on its own: tell the page, unless it closed us. + fn end(&self, code: u16, reason: &str) { + if let Some(tx) = self.marker.upgrade() { + finished(&self.app, &self.id, &tx, code, reason); + } } } +async fn write_line(writer: &mut W, line: String) -> std::io::Result<()> { + let mut bytes = line.into_bytes(); + bytes.extend_from_slice(b"\r\n"); + writer.write_all(&bytes).await +} + +/// Write what is left, flush and shut down, all within DRAIN_TIMEOUT. +async fn drain(writer: &mut W, lines: Vec) { + let _ = tokio::time::timeout(DRAIN_TIMEOUT, async { + for line in lines { + if write_line(writer, line).await.is_err() { + return; + } + } + + let _ = writer.flush().await; + let _ = writer.shutdown().await; + }) + .await; +} + async fn dial(host: &str, port: u16, tls: bool) -> Result, String> { let addrs: Vec<_> = tokio::net::lookup_host((host, port)) .await @@ -310,22 +458,40 @@ async fn dial(host: &str, port: u16, tls: bool) -> Result, Strin let stream = tls_connector()? .connect(name, tcp) .await - .map_err(|e| e.to_string())?; + .map_err(tls_failure)?; Ok(Box::new(stream)) } +/// A failed handshake, in words a user can act on; rustls's own text after. +fn tls_failure(e: std::io::Error) -> String { + let detail = e.to_string(); + + match e + .get_ref() + .and_then(|inner| inner.downcast_ref::()) + { + Some(rustls::Error::InvalidCertificate(_)) => { + format!("the server's certificate is not trusted: {detail}") + } + _ => format!("TLS failed: {detail}"), + } +} + +/// Built once: Mozilla's roots on the ring provider. fn tls_connector() -> Result { - let roots = rustls::RootCertStore { - roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(), - }; - let config = rustls::ClientConfig::builder_with_provider(Arc::new( - rustls::crypto::ring::default_provider(), - )) - .with_safe_default_protocol_versions() - .map_err(|e| e.to_string())? - .with_root_certificates(roots) - .with_no_client_auth(); - Ok(tokio_rustls::TlsConnector::from(Arc::new(config))) + static CONFIG: OnceLock, String>> = OnceLock::new(); + let config = CONFIG.get_or_init(|| { + let roots = rustls::RootCertStore { + roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(), + }; + rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .map(|builder| Arc::new(builder.with_root_certificates(roots).with_no_client_auth())) + .map_err(|e| e.to_string()) + }); + config.clone().map(tokio_rustls::TlsConnector::from) } /// `[::1]` → `::1`; anything else as given, trimmed. @@ -342,7 +508,9 @@ pub fn port_allowed(port: u16) -> bool { } pub fn check_send_line(line: &str) -> Result<(), &'static str> { - if line.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) { + if line.is_empty() { + Err("an empty line") + } else if line.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) { Err("a line may not hold CR, LF or NUL") } else if line.len() > MAX_SEND_LINE { Err("line too long") @@ -351,6 +519,30 @@ pub fn check_send_line(line: &str) -> Result<(), &'static str> { } } +/// The command of a line the page sends (no tags or prefix from a client). +fn command_of(line: &str) -> &str { + line.split(' ').next().unwrap_or("") +} + +pub fn is_registration(line: &str) -> bool { + let command = command_of(line); + REGISTRATION.iter().any(|c| c.eq_ignore_ascii_case(command)) +} + +/// A server's first line: tags, a prefix (an ircd prefixes its numerics), +/// or a command an ircd sends unprompted. An HTTP status line, a Redis +/// `-ERR` or an SMTP `220` greeting is none of these. +pub fn looks_like_irc(line: &str) -> bool { + if line.starts_with('@') || line.starts_with(':') { + return true; + } + + let command = command_of(line); + ["PING", "NOTICE", "ERROR", "CAP", "AUTHENTICATE"] + .iter() + .any(|c| c.eq_ignore_ascii_case(command)) +} + /// The complete lines in `buf` (LF-terminated, a trailing CR stripped, empty /// ones dropped), decoded; the unterminated rest stays in `buf`. A line over /// MAX_LINE, finished or not, is an error. @@ -436,6 +628,7 @@ mod tests { assert!(check_send_line("PRIVMSG #a :hi").is_ok()); assert!(check_send_line("PRIVMSG #a :hi\r\nQUIT").is_err()); assert!(check_send_line("a\0b").is_err()); + assert!(check_send_line("").is_err()); assert!(check_send_line(&"a".repeat(MAX_SEND_LINE + 1)).is_err()); } @@ -450,6 +643,54 @@ mod tests { } } + #[test] + fn lets_only_registration_out_before_the_server_answers() { + for line in [ + "CAP LS 302", + "NICK me", + "USER me 0 * :me", + "AUTHENTICATE PLAIN", + "ping :probe", + "QUIT :bye", + "PASS x", + ] { + assert!(is_registration(line), "{line}"); + } + + for line in [ + "GET / HTTP/1.1", + "POST /containers/create HTTP/1.1", + "SET k v", + "PRIVMSG #a :x", + "Host: x", + ] { + assert!(!is_registration(line), "{line}"); + } + } + + #[test] + fn knows_an_irc_server_by_its_first_line() { + for line in [ + ":irc.test NOTICE * :*** Looking up", + "@time=x :s 001 me :hi", + "NOTICE AUTH :hi", + "PING :123", + "ERROR :Closing link", + ] { + assert!(looks_like_irc(line), "{line}"); + } + + for line in [ + "HTTP/1.1 400 Bad Request", + "-ERR unknown command", + "+OK", + "SSH-2.0-OpenSSH_9.6", + "220 smtp ready ESMTP", + ] { + assert!(!looks_like_irc(line), "{line}"); + } + } + #[test] fn strips_ipv6_brackets() { assert_eq!(clean_host(" [::1] "), "::1"); diff --git a/test/irc/saved-networks.ts b/test/irc/saved-networks.ts index 1ea121e21..e37b7c0f7 100644 --- a/test/irc/saved-networks.ts +++ b/test/irc/saved-networks.ts @@ -100,6 +100,22 @@ describe("saved-networks", function () { ); }); + it("lets the connect form switch a saved TCP network back to WebSocket", function () { + const tcp = saved.normalize({...entry(), transport: "tcp", port: 6697})!; + const merged = saved.mergeOptions(tcp, tcp.uuid, {host: tcp.host, port: 8443, tls: true}); + expect(saved.normalize(merged)).to.not.have.property("transport"); + expect( + saved.normalize(saved.mergeOptions(tcp, tcp.uuid, {transport: "tcp", nick: "bob"})) + ).to.include({transport: "tcp", nick: "bob"}); + }); + + it("keeps a TCP and a WebSocket entry for one host and port apart", function () { + saved.save({...entry(), uuid: "ws", port: 6697}); + saved.save({...entry(), uuid: "tcp", port: 6697, transport: "tcp"}); + expect(saved.findMatching("irc.example.org", 6697, "alice")?.uuid).to.equal("ws"); + expect(saved.findMatching("irc.example.org", 6697, "alice", "tcp")?.uuid).to.equal("tcp"); + }); + it("has TCP default ports beside the WebSocket ones", function () { expect(saved.defaultPort(true, "tcp")).to.equal(6697); expect(saved.defaultPort(false, "tcp")).to.equal(6667); diff --git a/test/irc/shellSocket.ts b/test/irc/shellSocket.ts index 2862fde82..4ca3ff02d 100644 --- a/test/irc/shellSocket.ts +++ b/test/irc/shellSocket.ts @@ -78,7 +78,13 @@ describe("ShellSocket", function () { await tick(); expect(bridge.calls[0]).to.deep.equal({ method: "open", - options: {id: "a", host: "irc.example.org", port: 6697, tls: true}, + options: { + id: "a", + page: bridge.calls[0].options.page, + host: "irc.example.org", + port: 6697, + tls: true, + }, }); expect(socket.readyState).to.equal(socket.CONNECTING); @@ -95,6 +101,55 @@ describe("ShellSocket", function () { }); }); + it("sends one page token with every open", function () { + const bridge = new FakeBridge(); + new ShellSocket(bridge, endpoint, "p1"); + new ShellSocket(bridge, endpoint, "p2"); + return tick().then(() => { + const [a, b] = bridge.calls.map((c) => c.options.page as string); + expect(a).to.match(/^[0-9a-f-]{36}$/); + expect(b).to.equal(a); + }); + }); + + it("fails like a refusal when the bridge throws at once", async function () { + const bridge = new FakeBridge(); + bridge.call = () => { + throw new Error("no IPC"); + }; + const socket = new ShellSocket(bridge, endpoint, "t"); + const log = record(socket); + await tick(); + expect(log).to.deep.equal(["error:no IPC", "close:1006:no IPC:false"]); + }); + + it("delivers the rest of a batch when a listener throws", function () { + const bridge = new FakeBridge(); + const socket = new ShellSocket(bridge, endpoint, "x"); + const got: string[] = []; + socket.addEventListener("message", (ev) => { + if (ev.data === "boom") { + throw new Error("listener failed"); + } + + got.push(String(ev.data)); + }); + bridge.emit("open", {id: "x"}); + // The throw is rethrown in a microtask; catch the scheduling instead. + const rethrows: Array<() => void> = []; + const real = globalThis.queueMicrotask; + globalThis.queueMicrotask = (fn) => rethrows.push(fn); + + try { + bridge.emit("lines", {id: "x", lines: ["a", "boom", "b"]}); + } finally { + globalThis.queueMicrotask = real; + } + + expect(() => rethrows.forEach((fn) => fn())).to.throw("listener failed"); + expect(got).to.deep.equal(["a", "b"]); + }); + it("throws on send before it is open", function () { const socket = new ShellSocket(new FakeBridge(), endpoint, "b"); expect(() => socket.send("NICK me")).to.throw(/not open/); From afb55613acf894f9f92d21d2526d64d2046ca869 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 16:23:51 +0000 Subject: [PATCH 3/6] IRC over TCP: say why a certificate is untrusted in plain words --- shells/tauri/src-tauri/src/socket.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/shells/tauri/src-tauri/src/socket.rs b/shells/tauri/src-tauri/src/socket.rs index 55d1996fe..c8cfec3cb 100644 --- a/shells/tauri/src-tauri/src/socket.rs +++ b/shells/tauri/src-tauri/src/socket.rs @@ -470,8 +470,18 @@ fn tls_failure(e: std::io::Error) -> String { .get_ref() .and_then(|inner| inner.downcast_ref::()) { - Some(rustls::Error::InvalidCertificate(_)) => { - format!("the server's certificate is not trusted: {detail}") + Some(rustls::Error::InvalidCertificate(cert)) => { + use rustls::CertificateError as C; + // The plain reason, not rustls's debug text (`Other(OtherError(…))`). + let why = match cert { + C::Expired | C::ExpiredContext { .. } => " (it has expired)", + C::NotValidForName | C::NotValidForNameContext { .. } => { + " (it is for a different name)" + } + C::UnknownIssuer => " (it is signed by an unknown authority)", + _ => " (it may be self-signed)", + }; + format!("the server's certificate is not trusted{why}") } _ => format!("TLS failed: {detail}"), } From 6cdc2b994be709c63280e5962f5384552f22421f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 17:56:53 +0000 Subject: [PATCH 4/6] IRC over TCP: no retry for a failure a retry cannot mend; the page may call seance_call alone --- client/js/irc/shellSocket.ts | 15 ++++-- client/js/irc/transport.ts | 11 +++-- docs/resources/client-features.md | 2 +- shells/tauri/.gitignore | 3 ++ shells/tauri/src-tauri/build.rs | 9 +++- .../tauri/src-tauri/capabilities/default.json | 4 +- shells/tauri/src-tauri/src/socket.rs | 36 +++++++++----- test/irc/shellSocket.ts | 47 ++++++++++++++++++- 8 files changed, 103 insertions(+), 24 deletions(-) diff --git a/client/js/irc/shellSocket.ts b/client/js/irc/shellSocket.ts index 4b894a0d1..0892a1beb 100644 --- a/client/js/irc/shellSocket.ts +++ b/client/js/irc/shellSocket.ts @@ -13,7 +13,8 @@ * - calls to the shell run in order per socket (open, sends, close): each * bridge call is its own IPC request and two may otherwise overtake each * other, which would reorder a pipelined registration or QUIT-then-close; - * - a failure fires `error` (carrying the shell's reason) then one `close`; + * - a failure fires `error` (carrying the shell's reason) then one `close`, + * marked `final` when the shell says a retry cannot mend it; * - `close()` from the page fires its own `close` event, once: the shell * says nothing about a socket the page closed. */ @@ -35,6 +36,9 @@ export interface TcpEndpoint { const PLUGIN = "Socket"; +/** The shell's close code for a failure a retry cannot mend (socket.rs `FINAL`). */ +export const FINAL_CLOSE = 4001; + type Listeners = Record<"open" | "message" | "error" | "close", Array<(ev: any) => void>>; /** Live sockets per bridge, by id; the bridge's events are routed through here. */ @@ -140,7 +144,7 @@ export class ShellSocket implements LineSocket { addEventListener(type: "error", listener: (ev: unknown) => void): void; addEventListener( type: "close", - listener: (ev: {code: number; reason: string; wasClean: boolean}) => void + listener: (ev: {code: number; reason: string; wasClean: boolean; final?: boolean}) => void ): void; addEventListener(type: keyof Listeners, listener: (ev: any) => void): void { this.listeners[type].push(listener); @@ -237,7 +241,12 @@ export class ShellSocket implements LineSocket { this.state = this.CLOSED; this.sockets.delete(this.id); - this.fire("close", {code, reason, wasClean: code === 1000}); + this.fire("close", { + code, + reason, + wasClean: code === 1000, + ...(code === FINAL_CLOSE ? {final: true} : {}), + }); } private fire(type: K, ev: unknown): void { diff --git a/client/js/irc/transport.ts b/client/js/irc/transport.ts index 707cca063..c7783dc62 100644 --- a/client/js/irc/transport.ts +++ b/client/js/irc/transport.ts @@ -46,7 +46,9 @@ export interface LineSocket { addEventListener(type: "error", listener: (ev: unknown) => void): void; addEventListener( type: "close", - listener: (ev: {code: number; reason: string; wasClean: boolean}) => void + // `final`: a failure the next dial would repeat (ShellSocket's + // untrusted certificate); no retry. A WebSocket never sets it. + listener: (ev: {code: number; reason: string; wasClean: boolean; final?: boolean}) => void ): void; send(data: string): void; close(code?: number, reason?: string): void; @@ -258,7 +260,7 @@ export class WsTransport { ws.addEventListener("close", (ev) => { if (ws === this.ws) { this.ws = null; - this.handleClosed(ev.code, ev.reason, ev.wasClean); + this.handleClosed(ev.code, ev.reason, ev.wasClean, undefined, ev.final === true); } }); } @@ -419,7 +421,8 @@ export class WsTransport { code: number, reason: string, wasClean: boolean, - cause?: "timeout" | "probe" + cause?: "timeout" | "probe", + final = false ): void { // A socket that dies while we are probing it (the foreground poke // asked "are you alive?" and the OS answered with the close, or @@ -461,7 +464,7 @@ export class WsTransport { } this.openedAt = null; - const retry = !this.closedByUs && this.opts.reconnect.enabled; + const retry = !this.closedByUs && !final && this.opts.reconnect.enabled; let delayMs: number | undefined; let why: RetryReason | undefined; diff --git a/docs/resources/client-features.md b/docs/resources/client-features.md index 0b436f58e..c2a490d97 100644 --- a/docs/resources/client-features.md +++ b/docs/resources/client-features.md @@ -110,7 +110,7 @@ How the parts of the client behave and why, moved out of `CLAUDE.md` on 2026-11- ## IRC over TCP (the app only) -A network can be reached over plain TCP or TLS instead of WebSocket, in the app shell only: a browser cannot open a raw socket (Direct Sockets is for Isolated Web Apps on managed ChromeOS). `ConnectOptions.transport: "tcp"` (absent = WebSocket; default ports 6667/6697, `saved-networks.ts` `defaultPort`) is set by the "Connection" select in `Connect.vue` and `NetworkForm.vue`, shown only inside a shell; `mergeOptions` lets the connect form switch a saved TCP network back, and `findMatching` keys on it. `WsTransport` keeps its whole policy and takes the socket through `TransportOptions.WebSocketImpl`, typed as `LineSocket` (the part of `WebSocket` it uses): `client.ts` `createTransport` hands it `shellSocket.ts` `ShellSocket`, bound to the endpoint, which calls the shell's `Socket` plugin (`shells/tauri/src-tauri/src/socket.rs`) through `IrcClientOptions.socketBridge` (`manager.ts` passes `window.seanceShell`). ShellSocket keeps one promise chain of shell calls per socket (open, the lines of a tick as one send, close), fires its own close on `close()`, and error-then-close on a failure with the shell's reason; a page token with every open lets the shell refuse an open from the page before a reload. Without a bridge (a browser) a TCP network refuses to connect (`connect.tcpNeedsApp`). The shell does framing (CRLF out; lines split, 16 KiB cap, UTF-8 else Latin-1 in), TLS (rustls on ring, Mozilla's roots from `webpki-roots`; no pinning, so a self-signed server is reachable without TLS only) and the limits a page-reachable raw socket needs: the Fetch spec's bad ports less the IRC ones, no empty line or CR/LF/NUL, only registration commands until the server's first line looks like IRC (else the socket closes), a 512-line queue, 10 s per write, 16 sockets; a page load closes them after a 1 s grace for the `pagehide` QUIT. STS policies are kept per transport (`sts.ts` `stsHost`: `tcp:host`), since one server's WebSocket and IRC TLS ports differ; a WebSocket policy does not upgrade a first plain-TCP dial. `disconnect.ts` gives TCP its own hints. Tests `test/irc/shellSocket.ts` (also under `WsTransport`), the `transport` cases in `test/irc/{client,saved-networks,sts,disconnect}.ts`, and `cargo test` in `shells/tauri/src-tauri` (framing, decoding, the send and first-line gates, ports, a dial to a local socket). +A network can be reached over plain TCP or TLS instead of WebSocket, in the app shell only: a browser cannot open a raw socket (Direct Sockets is for Isolated Web Apps on managed ChromeOS). `ConnectOptions.transport: "tcp"` (absent = WebSocket; default ports 6667/6697, `saved-networks.ts` `defaultPort`) is set by the "Connection" select in `Connect.vue` and `NetworkForm.vue`, shown only inside a shell; `mergeOptions` lets the connect form switch a saved TCP network back, and `findMatching` keys on it. `WsTransport` keeps its whole policy and takes the socket through `TransportOptions.WebSocketImpl`, typed as `LineSocket` (the part of `WebSocket` it uses): `client.ts` `createTransport` hands it `shellSocket.ts` `ShellSocket`, bound to the endpoint, which calls the shell's `Socket` plugin (`shells/tauri/src-tauri/src/socket.rs`) through `IrcClientOptions.socketBridge` (`manager.ts` passes `window.seanceShell`). ShellSocket keeps one promise chain of shell calls per socket (open, the lines of a tick as one send, close), fires its own close on `close()`, and error-then-close on a failure with the shell's reason; a page token with every open lets the shell refuse an open from the page before a reload. Without a bridge (a browser) a TCP network refuses to connect (`connect.tcpNeedsApp`). The shell does framing (CRLF out; lines split, 16 KiB cap, UTF-8 else Latin-1 in), TLS (rustls on ring, Mozilla's roots from `webpki-roots`; no pinning, so a self-signed server is reachable without TLS only) and the limits a page-reachable raw socket needs: the Fetch spec's bad ports less the IRC ones, no empty line or CR/LF/NUL, only registration commands until the server's first line looks like IRC (else the socket closes), a 512-line queue, 10 s per write, 16 sockets; a page load closes them after a 1 s grace for the `pagehide` QUIT. A failure the next dial would repeat (an untrusted certificate, a server that does not speak IRC) closes with `FINAL_CLOSE` (4001): ShellSocket marks that close `final` and `WsTransport` does not retry it. The page may call the shell's one command, `seance_call`, from the `main` window only (`build.rs` `AppManifest`, `capabilities/default.json`). STS policies are kept per transport (`sts.ts` `stsHost`: `tcp:host`), since one server's WebSocket and IRC TLS ports differ; a WebSocket policy does not upgrade a first plain-TCP dial. `disconnect.ts` gives TCP its own hints. Tests `test/irc/shellSocket.ts` (also under `WsTransport`), the `transport` cases in `test/irc/{client,saved-networks,sts,disconnect}.ts`, and `cargo test` in `shells/tauri/src-tauri` (framing, decoding, the send and first-line gates, ports, a dial to a local socket). ## Translation diff --git a/shells/tauri/.gitignore b/shells/tauri/.gitignore index 06ee7dc08..039f61f6c 100644 --- a/shells/tauri/.gitignore +++ b/shells/tauri/.gitignore @@ -4,3 +4,6 @@ src-tauri/target/ # carries over); never edited by hand. src-tauri/icons/ src-tauri/gen/schemas/ + +# Generated by tauri-build from build.rs AppManifest. +src-tauri/permissions/autogenerated/ diff --git a/shells/tauri/src-tauri/build.rs b/shells/tauri/src-tauri/build.rs index 78f38526f..c07d6bb4e 100644 --- a/shells/tauri/src-tauri/build.rs +++ b/shells/tauri/src-tauri/build.rs @@ -39,7 +39,14 @@ fn main() { println!("cargo:rerun-if-changed=swift/"); } - tauri_build::build() + // The page may call `seance_call` alone, and only from the windows + // capabilities/default.json names: without a list, Tauri lets every + // window call every registered command. + tauri_build::try_build( + tauri_build::Attributes::new() + .app_manifest(tauri_build::AppManifest::new().commands(&["seance_call"])), + ) + .expect("tauri build") } /// Tauri's Swift API without its build products and tests. diff --git a/shells/tauri/src-tauri/capabilities/default.json b/shells/tauri/src-tauri/capabilities/default.json index ce3dc5c59..81a45ea81 100644 --- a/shells/tauri/src-tauri/capabilities/default.json +++ b/shells/tauri/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "The app's own page. It reaches the shell through the seance_call command alone (src/lib.rs); no Tauri plugin API is exposed to it.", + "description": "The app's own page. It reaches the shell through the seance_call command alone (src/lib.rs, restricted to it in build.rs); no Tauri plugin API is exposed to it.", "windows": ["main"], - "permissions": [] + "permissions": ["allow-seance-call"] } diff --git a/shells/tauri/src-tauri/src/socket.rs b/shells/tauri/src-tauri/src/socket.rs index c8cfec3cb..3fa25ed3f 100644 --- a/shells/tauri/src-tauri/src/socket.rs +++ b/shells/tauri/src-tauri/src/socket.rs @@ -15,7 +15,9 @@ // // `Socket.open {id, page, host, port, tls}` → events `open {id}`, `lines // {id, lines}` (one per read), `close {id, code, reason}`: 1000 when the -// server ended the connection, 1006 for any failure. `Socket.send {id, +// server ended the connection, FINAL (4001) for a failure a retry cannot +// mend (an untrusted certificate, a server that is not IRC), 1006 for any +// other. `Socket.send {id, // lines}`, once open. `Socket.close {id}`: the queued lines are written (a // QUIT gets out), the socket shut, and no event follows. A page load closes // every socket, and an open from the page before it (`page`, a token per @@ -325,7 +327,7 @@ impl Task { let stream = tokio::select! { result = dial => match result { Ok(Ok(stream)) => stream, - Ok(Err(reason)) => return self.end(1006, &reason), + Ok(Err((code, reason))) => return self.end(code, &reason), Err(_) => return self.end(1006, "connect timed out"), }, // Closed by the page while dialling (`send` refuses lines until @@ -351,7 +353,7 @@ impl Task { Ok(lines) => { if !self.greeted.load(Ordering::SeqCst) { if !looks_like_irc(&lines[0]) { - return self.end(1006, "the server does not speak IRC"); + return self.end(FINAL, "the server does not speak IRC"); } self.greeted.store(true, Ordering::SeqCst); @@ -425,10 +427,15 @@ async fn drain(writer: &mut W, lines: Vec) { .await; } -async fn dial(host: &str, port: u16, tls: bool) -> Result, String> { +/// A close code of the application range (as WebSocket's 4000–4999): the +/// page does not retry it, since the next dial would fail the same way. +const FINAL: u16 = 4001; + +/// Dial `host`; a failure is its close code and reason. +async fn dial(host: &str, port: u16, tls: bool) -> Result, (u16, String)> { let addrs: Vec<_> = tokio::net::lookup_host((host, port)) .await - .map_err(|e| format!("cannot resolve {host}: {e}"))? + .map_err(|e| (1006, format!("cannot resolve {host}: {e}")))? .collect(); let mut last = format!("{host} has no address"); let mut tcp = None; @@ -446,7 +453,7 @@ async fn dial(host: &str, port: u16, tls: bool) -> Result, Strin } } - let tcp = tcp.ok_or(last)?; + let tcp = tcp.ok_or((1006, last))?; let _ = tcp.set_nodelay(true); if !tls { @@ -454,16 +461,18 @@ async fn dial(host: &str, port: u16, tls: bool) -> Result, Strin } let name = rustls::pki_types::ServerName::try_from(host.to_owned()) - .map_err(|_| format!("{host} is not a valid TLS server name"))?; - let stream = tls_connector()? + .map_err(|_| (FINAL, format!("{host} is not a valid TLS server name")))?; + let stream = tls_connector() + .map_err(|reason| (1006, reason))? .connect(name, tcp) .await .map_err(tls_failure)?; Ok(Box::new(stream)) } -/// A failed handshake, in words a user can act on; rustls's own text after. -fn tls_failure(e: std::io::Error) -> String { +/// A failed handshake, in words a user can act on. A certificate that is +/// not trusted now will not be on the next dial: final. +fn tls_failure(e: std::io::Error) -> (u16, String) { let detail = e.to_string(); match e @@ -481,9 +490,12 @@ fn tls_failure(e: std::io::Error) -> String { C::UnknownIssuer => " (it is signed by an unknown authority)", _ => " (it may be self-signed)", }; - format!("the server's certificate is not trusted{why}") + ( + FINAL, + format!("the server's certificate is not trusted{why}"), + ) } - _ => format!("TLS failed: {detail}"), + _ => (1006, format!("TLS failed: {detail}")), } } diff --git a/test/irc/shellSocket.ts b/test/irc/shellSocket.ts index 4ca3ff02d..21d156b93 100644 --- a/test/irc/shellSocket.ts +++ b/test/irc/shellSocket.ts @@ -1,5 +1,10 @@ import {expect} from "chai"; -import {ShellSocket, SocketBridge, shellSocketCtor} from "../../client/js/irc/shellSocket"; +import { + FINAL_CLOSE, + ShellSocket, + SocketBridge, + shellSocketCtor, +} from "../../client/js/irc/shellSocket"; import {WsTransport, TransportEvent} from "../../client/js/irc/transport"; /** A stand-in for `window.seanceShell`'s Socket plugin, driven by the tests. */ @@ -218,6 +223,17 @@ describe("ShellSocket", function () { expect(log).to.deep.equal(["open:1", "close:1000::true"]); }); + it("marks a failure the shell calls final", function () { + const bridge = new FakeBridge(); + const socket = new ShellSocket(bridge, endpoint, "fin"); + const closes: unknown[] = []; + socket.addEventListener("close", (ev) => closes.push(ev)); + bridge.emit("close", {id: "fin", code: FINAL_CLOSE, reason: "certificate"}); + expect(closes).to.deep.equal([ + {code: FINAL_CLOSE, reason: "certificate", wasClean: false, final: true}, + ]); + }); + it("turns a refused open into error then close", async function () { const bridge = new FakeBridge(); bridge.refuse.set("open", "port 25 is not allowed"); @@ -300,6 +316,35 @@ describe("ShellSocket", function () { expect(close.willReconnect).to.equal(false); }); + it("retries a lost dial but not a final one", async function () { + for (const [code, retries] of [ + [1006, true], + [FINAL_CLOSE, false], + ] as const) { + const bridge = new FakeBridge(); + const transport = new WsTransport({ + url: "ircs://irc.example.org:6697", + WebSocketImpl: shellSocketCtor(bridge, endpoint), + reconnect: { + enabled: true, + initialDelayMs: 60000, + maxDelayMs: 60000, + factor: 1, + jitter: false, + }, + }); + const events: TransportEvent[] = []; + transport.on((ev) => events.push(ev)); + transport.connect(); + await tick(); + bridge.emit("close", {id: bridge.calls[0].options.id, code, reason: "x"}); + const close = events.find((e) => e.type === "close") as {willReconnect: boolean}; + expect(close.willReconnect, String(code)).to.equal(retries); + expect(transport.state).to.equal(retries ? "reconnect-wait" : "closed"); + transport.close(); + } + }); + it("abandons a dial: the old socket is closed in the shell, its late open ignored", async function () { const bridge = new FakeBridge(); const transport = new WsTransport({ From b1b7d70bb90892e700036d7725fb41c892315ce0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 18:11:47 +0000 Subject: [PATCH 5/6] IRC over TCP: no nested brackets in the certificate message --- shells/tauri/src-tauri/src/socket.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shells/tauri/src-tauri/src/socket.rs b/shells/tauri/src-tauri/src/socket.rs index 3fa25ed3f..34cb3ccac 100644 --- a/shells/tauri/src-tauri/src/socket.rs +++ b/shells/tauri/src-tauri/src/socket.rs @@ -483,12 +483,12 @@ fn tls_failure(e: std::io::Error) -> (u16, String) { use rustls::CertificateError as C; // The plain reason, not rustls's debug text (`Other(OtherError(…))`). let why = match cert { - C::Expired | C::ExpiredContext { .. } => " (it has expired)", + C::Expired | C::ExpiredContext { .. } => ": it has expired", C::NotValidForName | C::NotValidForNameContext { .. } => { - " (it is for a different name)" + ": it is for a different name" } - C::UnknownIssuer => " (it is signed by an unknown authority)", - _ => " (it may be self-signed)", + C::UnknownIssuer => ": it is signed by an unknown authority", + _ => ": it may be self-signed", }; ( FINAL, From 0387f3c5e149ac4f9ea65a82e35e281a16bd2129 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 18:51:45 +0000 Subject: [PATCH 6/6] IRC over TCP: fixes from the second review - 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. --- client/components/NetworkForm.vue | 2 +- client/components/Windows/Connect.vue | 16 +--- client/js/irc/client.ts | 4 +- client/js/irc/saved-networks.ts | 18 +--- client/js/irc/types.ts | 6 +- docs/resources/client-features.md | 2 +- shells/tauri/src-tauri/Cargo.toml | 3 - shells/tauri/src-tauri/src/socket.rs | 117 +++++++++++++++++--------- test/irc/saved-networks.ts | 17 ++-- 9 files changed, 102 insertions(+), 83 deletions(-) diff --git a/client/components/NetworkForm.vue b/client/components/NetworkForm.vue index 853947ad8..f839bfd4d 100644 --- a/client/components/NetworkForm.vue +++ b/client/components/NetworkForm.vue @@ -851,7 +851,7 @@ export default defineComponent({ props.defaults.port = tcp ? defaultPort(tls, "tcp") : tls ? 8443 : 8067; } - props.defaults.transport = tcp ? "tcp" : undefined; + props.defaults.transport = tcp ? "tcp" : "websocket"; }, }); diff --git a/client/components/Windows/Connect.vue b/client/components/Windows/Connect.vue index ee0e2d502..9fad58605 100644 --- a/client/components/Windows/Connect.vue +++ b/client/components/Windows/Connect.vue @@ -501,7 +501,7 @@ export default defineComponent({ const pinServer = () => { if (hostLocked) { Object.assign(form, server); - delete form.transport; // the deploy's server is a WebSocket one + form.transport = "websocket"; // the deploy's server is a WebSocket one } }; @@ -510,17 +510,13 @@ export default defineComponent({ const connection = computed({ get: () => form.transport ?? "websocket", set(value: string) { - const next = value === "tcp" ? "tcp" : undefined; + const next = value === "tcp" ? "tcp" : "websocket"; if (form.port === defaultPort(form.tls, form.transport)) { form.port = defaultPort(form.tls, next); } - if (next) { - form.transport = next; - } else { - delete form.transport; - } + form.transport = next; }, }); @@ -576,11 +572,7 @@ export default defineComponent({ form.port = net.port; form.tls = net.tls; - if (net.transport) { - form.transport = net.transport; - } else { - delete form.transport; - } + form.transport = net.transport ?? "websocket"; form.nick = net.nick; form.join = net.join; diff --git a/client/js/irc/client.ts b/client/js/irc/client.ts index 88147b83c..7339720ee 100644 --- a/client/js/irc/client.ts +++ b/client/js/irc/client.ts @@ -970,7 +970,7 @@ export class IrcClient { next.host !== o.host || next.port !== o.port || next.tls !== o.tls || - next.transport !== o.transport + (next.transport === "tcp") !== (o.transport === "tcp") ); } @@ -1104,7 +1104,7 @@ export class IrcClient { if ( pending && - pending.transport === this.options.transport && + (pending.transport === "tcp") === (this.options.transport === "tcp") && policyHost(pending.host) === policyHost(this.options.host) ) { this.pendingOptions = {...pending, ...secure}; diff --git a/client/js/irc/saved-networks.ts b/client/js/irc/saved-networks.ts index 67f94b28b..4c7a3173e 100644 --- a/client/js/irc/saved-networks.ts +++ b/client/js/irc/saved-networks.ts @@ -373,12 +373,6 @@ export function fromForm(data: Record, existing?: SavedNetwork) } } - // The connection select sends `websocket` (or a cleared value) to go - // back from TCP: the loop above would keep the stored `tcp` for it. - if ("transport" in data && data.transport !== "tcp") { - delete base.transport; - } - // A blank password field in the edit form means "keep what I had". if (existing && asString(data.saslPassword) === "" && existing.saslPassword) { base.saslPassword = existing.saslPassword; @@ -590,15 +584,11 @@ export function findMatching( hostnameOf(net.host).toLowerCase() === h && net.port === port && net.nick.toLowerCase() === n && - net.transport === transport + (net.transport === "tcp") === (transport === "tcp") ); } -/** - * The stored entry with the connect options laid over it (undefined options - * keep the stored value). The connection type is the exception: options - * without `transport: "tcp"` are a WebSocket connection, whatever was stored. - */ +/** The stored entry with the connect options laid over it (undefined options keep the stored value). */ export function mergeOptions( existing: SavedNetwork | undefined, uuid: string, @@ -612,9 +602,5 @@ export function mergeOptions( } } - if (options.transport !== "tcp") { - delete merged.transport; - } - return merged; } diff --git a/client/js/irc/types.ts b/client/js/irc/types.ts index 73654e11d..caeeb9fd6 100644 --- a/client/js/irc/types.ts +++ b/client/js/irc/types.ts @@ -42,9 +42,11 @@ export type ConnectOptions = { tls: boolean; /** * `tcp`: plain IRC over TCP (TLS when `tls`), made by the app shell - * (shellSocket.ts); unavailable in a browser. Absent: IRC over WebSocket. + * (shellSocket.ts); unavailable in a browser. `websocket` or absent: IRC + * over WebSocket. The forms send `websocket` to switch back (an absent + * option keeps the stored value); a saved entry never holds it. */ - transport?: "tcp"; + transport?: "tcp" | "websocket"; nick: string; /** Comma-separated channel list, optionally with keys (`#a key, #b`). */ join: string; diff --git a/docs/resources/client-features.md b/docs/resources/client-features.md index c2a490d97..f0e6d85b6 100644 --- a/docs/resources/client-features.md +++ b/docs/resources/client-features.md @@ -110,7 +110,7 @@ How the parts of the client behave and why, moved out of `CLAUDE.md` on 2026-11- ## IRC over TCP (the app only) -A network can be reached over plain TCP or TLS instead of WebSocket, in the app shell only: a browser cannot open a raw socket (Direct Sockets is for Isolated Web Apps on managed ChromeOS). `ConnectOptions.transport: "tcp"` (absent = WebSocket; default ports 6667/6697, `saved-networks.ts` `defaultPort`) is set by the "Connection" select in `Connect.vue` and `NetworkForm.vue`, shown only inside a shell; `mergeOptions` lets the connect form switch a saved TCP network back, and `findMatching` keys on it. `WsTransport` keeps its whole policy and takes the socket through `TransportOptions.WebSocketImpl`, typed as `LineSocket` (the part of `WebSocket` it uses): `client.ts` `createTransport` hands it `shellSocket.ts` `ShellSocket`, bound to the endpoint, which calls the shell's `Socket` plugin (`shells/tauri/src-tauri/src/socket.rs`) through `IrcClientOptions.socketBridge` (`manager.ts` passes `window.seanceShell`). ShellSocket keeps one promise chain of shell calls per socket (open, the lines of a tick as one send, close), fires its own close on `close()`, and error-then-close on a failure with the shell's reason; a page token with every open lets the shell refuse an open from the page before a reload. Without a bridge (a browser) a TCP network refuses to connect (`connect.tcpNeedsApp`). The shell does framing (CRLF out; lines split, 16 KiB cap, UTF-8 else Latin-1 in), TLS (rustls on ring, Mozilla's roots from `webpki-roots`; no pinning, so a self-signed server is reachable without TLS only) and the limits a page-reachable raw socket needs: the Fetch spec's bad ports less the IRC ones, no empty line or CR/LF/NUL, only registration commands until the server's first line looks like IRC (else the socket closes), a 512-line queue, 10 s per write, 16 sockets; a page load closes them after a 1 s grace for the `pagehide` QUIT. A failure the next dial would repeat (an untrusted certificate, a server that does not speak IRC) closes with `FINAL_CLOSE` (4001): ShellSocket marks that close `final` and `WsTransport` does not retry it. The page may call the shell's one command, `seance_call`, from the `main` window only (`build.rs` `AppManifest`, `capabilities/default.json`). STS policies are kept per transport (`sts.ts` `stsHost`: `tcp:host`), since one server's WebSocket and IRC TLS ports differ; a WebSocket policy does not upgrade a first plain-TCP dial. `disconnect.ts` gives TCP its own hints. Tests `test/irc/shellSocket.ts` (also under `WsTransport`), the `transport` cases in `test/irc/{client,saved-networks,sts,disconnect}.ts`, and `cargo test` in `shells/tauri/src-tauri` (framing, decoding, the send and first-line gates, ports, a dial to a local socket). +A network can be reached over plain TCP or TLS instead of WebSocket, in the app shell only: a browser cannot open a raw socket (Direct Sockets is for Isolated Web Apps on managed ChromeOS). `ConnectOptions.transport: "tcp"` (absent = WebSocket; default ports 6667/6697, `saved-networks.ts` `defaultPort`) is set by the "Connection" select in `Connect.vue` and `NetworkForm.vue`, shown only inside a shell; `mergeOptions` lets the connect form switch a saved TCP network back, and `findMatching` keys on it. `WsTransport` keeps its whole policy and takes the socket through `TransportOptions.WebSocketImpl`, typed as `LineSocket` (the part of `WebSocket` it uses): `client.ts` `createTransport` hands it `shellSocket.ts` `ShellSocket`, bound to the endpoint, which calls the shell's `Socket` plugin (`shells/tauri/src-tauri/src/socket.rs`) through `IrcClientOptions.socketBridge` (`manager.ts` passes `window.seanceShell`). ShellSocket keeps one promise chain of shell calls per socket (open, the lines of a tick as one send, close), fires its own close on `close()`, and error-then-close on a failure with the shell's reason; a page token with every open lets the shell refuse an open from the page before a reload. Without a bridge (a browser) a TCP network refuses to connect (`connect.tcpNeedsApp`). The shell does framing (CRLF out; lines split, 16 KiB cap, UTF-8 else Latin-1 in), TLS (rustls on ring, Mozilla's roots from `webpki-roots`; no pinning, so a self-signed server is reachable without TLS only) and the limits a page-reachable raw socket needs: the Fetch spec's bad ports less the IRC ones, no empty line or CR/LF/NUL, only registration commands until the server's first line looks like IRC (a prefix or tags, or `PING`/`NOTICE`/`ERROR` with a parameter; else the socket closes), a 512-line queue, 10 s per write, 16 sockets and 30 opens a minute; a page load closes them after a 1 s grace for the `pagehide` QUIT. A failure the next dial would repeat (an untrusted certificate, a server that does not speak IRC) closes with `FINAL_CLOSE` (4001): ShellSocket marks that close `final` and `WsTransport` does not retry it. The page may call the shell's one command, `seance_call`, from the `main` window only (`build.rs` `AppManifest`, `capabilities/default.json`). STS policies are kept per transport (`sts.ts` `stsHost`: `tcp:host`), since one server's WebSocket and IRC TLS ports differ; a WebSocket policy does not upgrade a first plain-TCP dial. `disconnect.ts` gives TCP its own hints. Tests `test/irc/shellSocket.ts` (also under `WsTransport`), the `transport` cases in `test/irc/{client,saved-networks,sts,disconnect}.ts`, and `cargo test` in `shells/tauri/src-tauri` (framing, decoding, the send and first-line gates, ports, a dial to a local socket). ## Translation diff --git a/shells/tauri/src-tauri/Cargo.toml b/shells/tauri/src-tauri/Cargo.toml index 546bc87c7..f34fd1ba9 100644 --- a/shells/tauri/src-tauri/Cargo.toml +++ b/shells/tauri/src-tauri/Cargo.toml @@ -29,9 +29,6 @@ tokio-rustls = { version = "=0.26.6", default-features = false, features = ["rin rustls = { version = "=0.23.46", default-features = false, features = ["ring", "std", "logging", "tls12"] } webpki-roots = "=1.0.11" -[dev-dependencies] -tokio = { version = "=1.54.1", features = ["rt", "macros", "net", "io-util"] } - [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-single-instance = "=2.5.5" tauri-plugin-window-state = "=2.5.0" diff --git a/shells/tauri/src-tauri/src/socket.rs b/shells/tauri/src-tauri/src/socket.rs index 34cb3ccac..8bb4b52af 100644 --- a/shells/tauri/src-tauri/src/socket.rs +++ b/shells/tauri/src-tauri/src/socket.rs @@ -11,7 +11,8 @@ // - until the server has said something that looks like IRC, only the // registration commands may be sent, and a first line that does not look // like IRC (an HTTP status, a Redis `-ERR`) closes the socket; -// - lines, queues and writes are capped; at most MAX_SOCKETS at once. +// - lines, queues and writes are capped; at most MAX_SOCKETS at once, and +// MAX_OPENS a minute (no scanning the LAN through it). // // `Socket.open {id, page, host, port, tls}` → events `open {id}`, `lines // {id, lines}` (one per read), `close {id, code, reason}`: 1000 when the @@ -24,10 +25,10 @@ // page load) is refused. use serde_json::{json, Value}; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; -use std::time::Duration; +use std::time::{Duration, Instant}; use tauri::{AppHandle, Manager}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::net::TcpStream; @@ -37,12 +38,16 @@ use crate::ShellError; const PLUGIN: &str = "Socket"; const MAX_SOCKETS: usize = 16; +/// Opens allowed per OPEN_WINDOW: enough for every network's quick retries +/// after a network change, too few for the page to scan a LAN with. +const MAX_OPENS: usize = 30; +const OPEN_WINDOW: Duration = Duration::from_secs(60); /// Queued outbound lines per socket; past it the socket is closed. const SEND_QUEUE: usize = 512; /// A sent line, before CRLF. The page caps lines at 500 bytes already. const MAX_SEND_LINE: usize = 4096; /// A received line; an ircd's own limit is 8191 + 512 bytes of tags. -pub const MAX_LINE: usize = 16 * 1024; +const MAX_LINE: usize = 16 * 1024; const ADDRESS_TIMEOUT: Duration = Duration::from_secs(5); const DIAL_DEADLINE: Duration = Duration::from_secs(20); const DRAIN_TIMEOUT: Duration = Duration::from_secs(2); @@ -90,8 +95,11 @@ struct State { retired: HashSet, /// The page before a reload's sockets, for PAGE_GONE_GRACE: WebKit /// starts the new load before the old page's `pagehide`, whose QUIT - /// must still get out. They say nothing more to the page. + /// must still get out. They are closed nowhere but here; what they + /// still read reaches no page route. retiring: HashMap, + /// When the last MAX_OPENS opens were accepted. + opens: VecDeque, } #[derive(Default)] @@ -187,6 +195,22 @@ pub fn call(app: &AppHandle, method: &str, options: &Value) -> Result= OPEN_WINDOW) + { + state.opens.pop_front(); + } + + if state.opens.len() >= MAX_OPENS { + return Err(refuse("too many connections opened; trying again shortly")); + } + + state.opens.push_back(now); + // In the map before the task starts: a close that comes // while it dials finds it, and the dial is abandoned. state.sockets.insert( @@ -241,12 +265,12 @@ pub fn call(app: &AppHandle, method: &str, options: &Value) -> Result Result>, + code: u16, + reason: &str, +) { let sockets = app.state::(); let mut state = sockets.0.lock().unwrap(); - state.retiring.remove(id); // a page that is gone hears nothing - let live = state.sockets.remove(id).is_some(); - drop(state); - if live { - crate::emit( - app, - PLUGIN, - "close", - json!({"id": id, "code": code, "reason": reason}), - false, - ); + if owner.is_none() { + state.retiring.remove(id); } -} - -/// Our socket ended on its own (the server, the network): tell the page, -/// unless the page closed it or a newer page owns the map. -fn finished(app: &AppHandle, id: &str, tx: &mpsc::Sender, code: u16, reason: &str) { - let sockets = app.state::(); - let mut state = sockets.0.lock().unwrap(); - if state + let ours = state .sockets .get(id) - .is_some_and(|ours| ours.tx.same_channel(tx)) - { + .is_some_and(|entry| owner.map_or(true, |tx| entry.tx.same_channel(tx))); + + if ours { state.sockets.remove(id); drop(state); crate::emit( @@ -401,7 +419,7 @@ impl Task { /// The socket ended on its own: tell the page, unless it closed us. fn end(&self, code: u16, reason: &str) { if let Some(tx) = self.marker.upgrade() { - finished(&self.app, &self.id, &tx, code, reason); + end_socket(&self.app, &self.id, Some(&tx), code, reason); } } } @@ -488,6 +506,14 @@ fn tls_failure(e: std::io::Error) -> (u16, String) { ": it is for a different name" } C::UnknownIssuer => ": it is signed by an unknown authority", + // The device's clock may be what is wrong: worth retrying. + C::NotValidYet | C::NotValidYetContext { .. } => { + return ( + 1006, + "the server's certificate is not valid yet: is this device's clock right?" + .into(), + ); + } _ => ": it may be self-signed", }; ( @@ -517,7 +543,7 @@ fn tls_connector() -> Result { } /// `[::1]` → `::1`; anything else as given, trimmed. -pub fn clean_host(host: &str) -> String { +fn clean_host(host: &str) -> String { let host = host.trim(); host.strip_prefix('[') .and_then(|h| h.strip_suffix(']')) @@ -525,11 +551,11 @@ pub fn clean_host(host: &str) -> String { .to_owned() } -pub fn port_allowed(port: u16) -> bool { +fn port_allowed(port: u16) -> bool { !BAD_PORTS.contains(&port) } -pub fn check_send_line(line: &str) -> Result<(), &'static str> { +fn check_send_line(line: &str) -> Result<(), &'static str> { if line.is_empty() { Err("an empty line") } else if line.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) { @@ -546,7 +572,7 @@ fn command_of(line: &str) -> &str { line.split(' ').next().unwrap_or("") } -pub fn is_registration(line: &str) -> bool { +fn is_registration(line: &str) -> bool { let command = command_of(line); REGISTRATION.iter().any(|c| c.eq_ignore_ascii_case(command)) } @@ -554,21 +580,27 @@ pub fn is_registration(line: &str) -> bool { /// A server's first line: tags, a prefix (an ircd prefixes its numerics), /// or a command an ircd sends unprompted. An HTTP status line, a Redis /// `-ERR` or an SMTP `220` greeting is none of these. -pub fn looks_like_irc(line: &str) -> bool { +fn looks_like_irc(line: &str) -> bool { if line.starts_with('@') || line.starts_with(':') { return true; } + // Without a prefix, only what a server says before it knows us, and + // with a parameter: a bare `ERROR` is memcached's answer to anything. let command = command_of(line); - ["PING", "NOTICE", "ERROR", "CAP", "AUTHENTICATE"] - .iter() - .any(|c| c.eq_ignore_ascii_case(command)) + let has_param = line + .split_once(' ') + .is_some_and(|(_, rest)| !rest.trim().is_empty()); + has_param + && ["PING", "NOTICE", "ERROR"] + .iter() + .any(|c| c.eq_ignore_ascii_case(command)) } /// The complete lines in `buf` (LF-terminated, a trailing CR stripped, empty /// ones dropped), decoded; the unterminated rest stays in `buf`. A line over /// MAX_LINE, finished or not, is an error. -pub fn take_lines(buf: &mut Vec) -> Result, &'static str> { +fn take_lines(buf: &mut Vec) -> Result, &'static str> { let mut lines = Vec::new(); let mut start = 0; @@ -602,7 +634,7 @@ pub fn take_lines(buf: &mut Vec) -> Result, &'static str> { } /// UTF-8, or Latin-1 for a line that is not (older networks and clients). -pub fn decode(bytes: &[u8]) -> String { +fn decode(bytes: &[u8]) -> String { match std::str::from_utf8(bytes) { Ok(text) => text.to_owned(), Err(_) => bytes.iter().map(|&b| b as char).collect(), @@ -708,6 +740,9 @@ mod tests { "+OK", "SSH-2.0-OpenSSH_9.6", "220 smtp ready ESMTP", + "ERROR", + "ERROR ", + "CAP LS 302", ] { assert!(!looks_like_irc(line), "{line}"); } diff --git a/test/irc/saved-networks.ts b/test/irc/saved-networks.ts index e37b7c0f7..0ff8ced7d 100644 --- a/test/irc/saved-networks.ts +++ b/test/irc/saved-networks.ts @@ -102,17 +102,24 @@ describe("saved-networks", function () { it("lets the connect form switch a saved TCP network back to WebSocket", function () { const tcp = saved.normalize({...entry(), transport: "tcp", port: 6697})!; - const merged = saved.mergeOptions(tcp, tcp.uuid, {host: tcp.host, port: 8443, tls: true}); - expect(saved.normalize(merged)).to.not.have.property("transport"); - expect( - saved.normalize(saved.mergeOptions(tcp, tcp.uuid, {transport: "tcp", nick: "bob"})) - ).to.include({transport: "tcp", nick: "bob"}); + const back = {host: tcp.host, port: 8443, tls: true, transport: "websocket" as const}; + expect(saved.normalize(saved.mergeOptions(tcp, tcp.uuid, back))).to.not.have.property( + "transport" + ); + // Options that do not name the connection type keep the stored one. + expect(saved.normalize(saved.mergeOptions(tcp, tcp.uuid, {nick: "bob"}))).to.include({ + transport: "tcp", + nick: "bob", + }); }); it("keeps a TCP and a WebSocket entry for one host and port apart", function () { saved.save({...entry(), uuid: "ws", port: 6697}); saved.save({...entry(), uuid: "tcp", port: 6697, transport: "tcp"}); expect(saved.findMatching("irc.example.org", 6697, "alice")?.uuid).to.equal("ws"); + expect(saved.findMatching("irc.example.org", 6697, "alice", "websocket")?.uuid).to.equal( + "ws" + ); expect(saved.findMatching("irc.example.org", 6697, "alice", "tcp")?.uuid).to.equal("tcp"); });