/quit now disconnects the window's server without closing anything: every buffer of that server and wIRC main get a "Disconnected" line, the server card shows Disconnected with its Connect button, history is flushed to disk, and there's no auto-reconnect. Connect rejoins the channels we were in (including ones joined with /join, now tracked) into the still-open windows. Sending while disconnected shows a "Not connected" line in that window. Exit wIRC still disconnects everything and closes the app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
305 lines
15 KiB
Kotlin
305 lines
15 KiB
Kotlin
package irc
|
|
|
|
import data.ServerConfig
|
|
import kotlinx.coroutines.*
|
|
import kotlinx.coroutines.channels.Channel
|
|
import java.io.BufferedReader
|
|
import java.io.InputStreamReader
|
|
import java.io.OutputStreamWriter
|
|
import java.io.PrintWriter
|
|
import java.net.Socket
|
|
import javax.net.ssl.SSLSocket
|
|
import javax.net.ssl.SSLSocketFactory
|
|
|
|
// TEMP DEBUG (see task: closed-channel-doesn't-receive-messages investigation) - remove
|
|
// once the overnight rejoin issue is confirmed fixed. Prefixed so it's easy to grep out.
|
|
private fun ircDebug(serverId: String, msg: String) {
|
|
println("[irc-debug][$serverId] $msg")
|
|
}
|
|
|
|
// Extra buffer after end-of-MOTD (376/422) before actually sending on-connect commands -
|
|
// see runPostRegistration(). Some networks' services genuinely aren't ready the same
|
|
// instant MOTD ends; this is cheap insurance on top of that already-real trigger.
|
|
private const val POST_MOTD_IDENTIFY_DELAY_MS = 1500L
|
|
|
|
// on-connect commands are meant to be raw IRC protocol lines (e.g.
|
|
// "PRIVMSG NickServ :IDENTIFY password"), but the app's own chat window teaches
|
|
// "/msg NickServ IDENTIFY password" as the correct thing to type (it IS correct there -
|
|
// ChannelWindow.kt's onSend translates that shorthand before sending) - an easy, natural
|
|
// mistake to reuse that exact phrasing in the on-connect-commands field, which has no such
|
|
// translation and just writes the string to the socket byte-for-byte. A real network
|
|
// confirmed this: the raw line got rejected outright with "421 Unknown command" before
|
|
// NickServ ever saw it, since IRC servers have no "/msg" command. This makes on-connect
|
|
// commands tolerant of that shorthand too, so either form works.
|
|
private fun translateOnConnectCommand(cmd: String): String {
|
|
val trimmed = cmd.trim()
|
|
if (!trimmed.startsWith("/msg ", ignoreCase = true)) return cmd
|
|
val rest = trimmed.substring(5).trim()
|
|
val spaceIdx = rest.indexOf(' ')
|
|
if (spaceIdx <= 0) return cmd
|
|
val target = rest.substring(0, spaceIdx)
|
|
val message = rest.substring(spaceIdx + 1).trim()
|
|
if (message.isEmpty()) return cmd
|
|
return "PRIVMSG $target :$message"
|
|
}
|
|
|
|
/**
|
|
* Owns one socket to one IRC server. Emits raw parsed lines via onLine.
|
|
* Handles registration (NICK/USER), and basic auto-reconnect with backoff.
|
|
*/
|
|
class IrcConnection(
|
|
val config: ServerConfig,
|
|
private val scope: CoroutineScope,
|
|
// Channels to rejoin on top of config.autoJoinChannels - the ones a previous connection
|
|
// to this server was in (see ConnectionManager.connect).
|
|
initialChannels: Collection<String> = emptyList(),
|
|
private val onLine: (ParsedLine) -> Unit,
|
|
private val onStatus: (String) -> Unit
|
|
) {
|
|
// @Volatile: read from the outbox-consumer coroutine, written from the read-loop
|
|
// coroutine - without it there's no cross-thread visibility guarantee, so the outbox
|
|
// consumer could keep observing a stale non-null `writer` from a socket that already
|
|
// closed (or a stale null after a new one connected), silently mis-routing sends.
|
|
@Volatile private var socket: Socket? = null
|
|
@Volatile private var writer: PrintWriter? = null
|
|
private var readJob: Job? = null
|
|
// @Volatile: disconnect() clears it from the UI thread while the read loop checks it on
|
|
// an IO thread - it's what stops the loop from auto-reconnecting after a /quit.
|
|
@Volatile private var wantConnected = false
|
|
private var backoffMillis = 2000L
|
|
|
|
// Every channel we're currently in, whether from config.autoJoinChannels or joined
|
|
// manually mid-session - so a reconnect rejoins everything, not just the original
|
|
// auto-join list.
|
|
private val joinedChannels = java.util.concurrent.CopyOnWriteArraySet<String>(initialChannels)
|
|
|
|
fun trackedChannels(): Set<String> = joinedChannels.toSet()
|
|
|
|
// Guards runPostRegistration() against running twice for the same connection cycle
|
|
// (376 and 422 are mutually exclusive per RFC, but this is cheap insurance) - reset
|
|
// to false at the top of every connect attempt so a reconnect re-arms it.
|
|
@Volatile private var registeredHandled = false
|
|
|
|
// Outgoing lines go through a single serial consumer instead of one
|
|
// scope.launch(Dispatchers.IO) per send() call. That old approach had two problems:
|
|
// (1) no ordering guarantee between independently-scheduled coroutines on a thread
|
|
// pool dispatcher, and (2) if the socket dropped between a send() call being made and
|
|
// its coroutine actually running, `writer` would already be null and the line was
|
|
// silently discarded with no trace - which made a dropped JOIN indistinguishable from
|
|
// one that was never requested. This queue preserves order and gives a single place
|
|
// to log what actually made it onto the wire vs. what got dropped.
|
|
private val outbox = Channel<String>(Channel.UNLIMITED)
|
|
|
|
init {
|
|
scope.launch(Dispatchers.IO) {
|
|
for (raw in outbox) {
|
|
val w = writer
|
|
if (w == null) {
|
|
// Kept (unlike the old per-line CHECKPOINT4 write-confirmation logging,
|
|
// removed - it fired on every single outbound line and was real
|
|
// console-I/O overhead under busy-channel volume, per println being
|
|
// synchronized/blocking) - this specific case should be rare and is
|
|
// worth knowing about if it ever happens.
|
|
ircDebug(config.id, "DROPPED (writer is null - not connected): \"$raw\"")
|
|
} else {
|
|
w.println(raw)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fun connect() {
|
|
wantConnected = true
|
|
readJob?.cancel()
|
|
readJob = scope.launch(Dispatchers.IO) {
|
|
while (wantConnected && isActive) {
|
|
var fallbackJob: Job? = null
|
|
try {
|
|
onStatus("Connecting to ${config.host}:${config.port}...")
|
|
ircDebug(config.id, "connecting to ${config.host}:${config.port} (tracked channels: $joinedChannels)")
|
|
val sock: Socket = if (config.useTls) {
|
|
val sslSocket = SSLSocketFactory.getDefault().createSocket(config.host, config.port) as SSLSocket
|
|
val sslParams = sslSocket.sslParameters
|
|
sslParams.endpointIdentificationAlgorithm = "HTTPS"
|
|
sslSocket.sslParameters = sslParams
|
|
sslSocket.startHandshake()
|
|
sslSocket
|
|
} else {
|
|
Socket(config.host, config.port)
|
|
}
|
|
socket = sock
|
|
// disconnect() can't interrupt a socket that's still being opened, so
|
|
// check here before registering on a connection nobody wants anymore.
|
|
ensureActive()
|
|
val out = PrintWriter(OutputStreamWriter(sock.getOutputStream(), Charsets.UTF_8), true)
|
|
writer = out
|
|
|
|
// Used to send "CAP REQ :sasl" here whenever config.saslUser was set, but
|
|
// there was never any AUTHENTICATE/CAP END follow-through - SASL isn't
|
|
// actually implemented (there's no UI to even set saslUser/saslPass). Per
|
|
// the IRCv3 CAP spec, a compliant server MUST hold off sending 001 until
|
|
// it sees CAP END, so starting a negotiation the client can never finish
|
|
// risked silently stalling registration forever on strict servers -
|
|
// meaning 001/376/422 (and therefore the on-connect identify command)
|
|
// would never fire at all. Removed until SASL is actually implemented
|
|
// end-to-end.
|
|
out.println("NICK ${config.nick}")
|
|
out.println("USER ${config.nick} 0 * :${config.nick}")
|
|
|
|
backoffMillis = 2000L
|
|
onStatus("Connected, registering...")
|
|
registeredHandled = false
|
|
// On-connect commands (identify, etc.) used to fire straight off 001 -
|
|
// technically "registered" but too early on plenty of real networks:
|
|
// NickServ/services in general aren't guaranteed ready for PRIVMSGs the
|
|
// instant 001 lands, which is exactly why real clients (mIRC, HexChat,
|
|
// irssi) fire "perform"/on-connect scripts off end-of-MOTD (376/422)
|
|
// instead - see runPostRegistration(). This matches that convention, with
|
|
// an 8s fallback in case a server never sends either (rare, but some
|
|
// minimal/bouncer-style endpoints omit MOTD numerics entirely).
|
|
fallbackJob = scope.launch {
|
|
delay(8000)
|
|
if (!registeredHandled) {
|
|
ircDebug(config.id, "no 376/422 within 8s of connecting - running perform+rejoin anyway (fallback)")
|
|
runPostRegistration()
|
|
}
|
|
}
|
|
|
|
val reader = BufferedReader(InputStreamReader(sock.getInputStream(), Charsets.UTF_8))
|
|
while (isActive) {
|
|
val line = reader.readLine() ?: break
|
|
handleRawLine(line)
|
|
}
|
|
} catch (e: Exception) {
|
|
// After disconnect() the read fails because we closed the socket
|
|
// ourselves - that's not an error worth reporting.
|
|
if (!wantConnected) break
|
|
onStatus("Connection error: ${e.message}")
|
|
ircDebug(config.id, "connection dropped: ${e.message} - tracked channels at drop time: $joinedChannels")
|
|
} finally {
|
|
fallbackJob?.cancel()
|
|
closeSocketQuietly()
|
|
}
|
|
|
|
if (wantConnected) {
|
|
onStatus("Reconnecting in ${backoffMillis / 1000}s...")
|
|
ircDebug(config.id, "reconnecting in ${backoffMillis}ms")
|
|
delay(backoffMillis)
|
|
backoffMillis = (backoffMillis * 2).coerceAtMost(60_000L)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun handleRawLine(line: String) {
|
|
// Used to log every single raw line here (RECV: "...") - removed. It was the
|
|
// single highest-volume debug line in the app: one per incoming line, every line,
|
|
// forever, and println is synchronized/blocking - a real, measurable cost in a
|
|
// busy channel, not just noise. The specific 001/376/422 confirmations below are
|
|
// once-per-connect and stay.
|
|
if (line.startsWith("PING")) {
|
|
val token = line.substringAfter("PING ")
|
|
send("PONG $token")
|
|
return
|
|
}
|
|
val parsed = parseIrcLine(line) ?: return
|
|
|
|
// CTCP requests arrive as a PRIVMSG wrapped in \x01...\x01. VERSION gets
|
|
// handled right here so it never shows up in a channel/PM window - real
|
|
// clients answer these silently too.
|
|
if (parsed.command == "PRIVMSG") {
|
|
val text = parsed.params.getOrNull(1)
|
|
if (text != null && text.startsWith("") && text.endsWith("") && text.length > 1) {
|
|
val ctcpBody = text.removePrefix("").removeSuffix("")
|
|
val ctcpCommand = ctcpBody.substringBefore(' ').uppercase()
|
|
if (ctcpCommand == "VERSION") {
|
|
val replyTo = nickFromPrefix(parsed.prefix)
|
|
if (replyTo != null) {
|
|
send("NOTICE $replyTo :VERSION It's an irc client, fucko!")
|
|
}
|
|
return
|
|
}
|
|
// Other CTCP types (ACTION, PING, etc.) fall through to onLine as normal -
|
|
// ACTION in particular still needs to reach ConnectionManager to render as /me.
|
|
}
|
|
}
|
|
|
|
if (parsed.command == "001") {
|
|
ircDebug(config.id, "registered (001) - awaiting end-of-MOTD (376/422) before perform+rejoin")
|
|
}
|
|
if (parsed.command == "376" || parsed.command == "422") {
|
|
runPostRegistration()
|
|
}
|
|
onLine(parsed)
|
|
}
|
|
|
|
// Runs the on-connect ("perform") commands - NickServ/ChanServ identify, etc. - then
|
|
// rejoins every channel we were actually in (the configured auto-join list plus
|
|
// anything joined manually mid-session, so a reconnect doesn't forget those). Fired
|
|
// off end-of-MOTD rather than bare 001 - see the comment in connect() for why - so
|
|
// identify commands land once the session is actually settled enough for services to
|
|
// reliably act on them.
|
|
private fun runPostRegistration() {
|
|
if (registeredHandled) return
|
|
registeredHandled = true
|
|
// The whole sequence (identify, then rejoin) runs inside this delayed coroutine
|
|
// rather than just delaying the identify send - keeping both here preserves "run
|
|
// identify before joining anything that might require it" (rejoin used to run
|
|
// synchronously right after, which would otherwise now race ahead of a delayed
|
|
// identify send and reach the wire first). The delay itself is a few extra hundred
|
|
// ms of insurance on top of an already-real event (376/422) rather than a fixed
|
|
// guess from connection time - some networks' NickServ genuinely isn't ready to
|
|
// process a command in the same instant MOTD ends.
|
|
scope.launch(Dispatchers.IO) {
|
|
delay(POST_MOTD_IDENTIFY_DELAY_MS)
|
|
config.onConnectCommands.forEach { cmd ->
|
|
if (cmd.isNotBlank()) send(translateOnConnectCommand(cmd))
|
|
}
|
|
val rejoinList = (config.autoJoinChannels + joinedChannels).distinct()
|
|
ircDebug(config.id, "post-registration - rejoining: $rejoinList")
|
|
rejoinList.forEach { ch -> joinChannel(ch) }
|
|
}
|
|
}
|
|
|
|
fun send(raw: String) {
|
|
outbox.trySend(raw)
|
|
}
|
|
|
|
fun sendMessage(target: String, text: String) {
|
|
send("PRIVMSG $target :$text")
|
|
}
|
|
|
|
fun joinChannel(channel: String) {
|
|
joinedChannels.add(channel)
|
|
ircDebug(config.id, "joinChannel($channel) - tracked set now: $joinedChannels")
|
|
send("JOIN $channel")
|
|
}
|
|
|
|
fun partChannel(channel: String, reason: String = "") {
|
|
joinedChannels.remove(channel)
|
|
ircDebug(config.id, "partChannel($channel) - tracked set now: $joinedChannels")
|
|
send("PART $channel :$reason")
|
|
}
|
|
fun kick(channel: String, nick: String, reason: String = "") = send("KICK $channel $nick :$reason")
|
|
fun setMode(channel: String, modeChange: String, nick: String) = send("MODE $channel $modeChange $nick")
|
|
fun ban(channel: String, mask: String) = send("MODE $channel +b $mask")
|
|
|
|
fun disconnect() {
|
|
wantConnected = false
|
|
scope.launch(Dispatchers.IO) {
|
|
try { writer?.println("QUIT :leaving") } catch (_: Exception) {}
|
|
readJob?.cancel()
|
|
closeSocketQuietly()
|
|
// Ends the outbox consumer - this connection is never reused (a reconnect
|
|
// makes a new IrcConnection), so it would otherwise idle forever.
|
|
outbox.close()
|
|
}
|
|
}
|
|
|
|
private fun closeSocketQuietly() {
|
|
try { socket?.close() } catch (_: Exception) {}
|
|
socket = null
|
|
writer = null
|
|
}
|
|
}
|