From 353827480521ec29dfefb5521f44941fb0b37515 Mon Sep 17 00:00:00 2001 From: weishaupt Date: Tue, 6 Oct 2026 12:46:59 -0400 Subject: [PATCH] Make bare /quit disconnect only that server, keep windows open, bump to 0.1.9 /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 --- build.gradle.kts | 4 +- src/main/kotlin/Main.kt | 12 +-- src/main/kotlin/data/AliasRepository.kt | 6 ++ src/main/kotlin/irc/ConnectionManager.kt | 93 ++++++++++++++++++++++-- src/main/kotlin/irc/IrcConnection.kt | 20 ++++- src/main/kotlin/ui/ChannelWindow.kt | 44 ++++++++--- src/main/kotlin/ui/MainScreen.kt | 14 ++-- 7 files changed, 162 insertions(+), 31 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index d7fb9c2..321dad1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "com.tpuahsiew.wirc" -version = "0.1.8" +version = "0.1.9" // No project-level repositories{} block here: settings.gradle.kts sets // dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already @@ -38,7 +38,7 @@ compose.desktop { appResourcesRootDir.set(project.file("installerResources")) targetFormats(TargetFormat.Msi, TargetFormat.Exe) packageName = "wIRC" - packageVersion = "0.1.8" + packageVersion = "0.1.9" windows { menuGroup = "wIRC" upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a" diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 5c852b4..ad421d7 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -58,9 +58,9 @@ fun main() = application { var openBuffers by remember { mutableStateOf(setOf()) } var mainUnread by remember { mutableStateOf(0) } - // Full shutdown: same steps /quit (bare, no args) already runs from a channel window - - // disconnect every server, then end the process. Shared here so the "Exit wIRC" button - // and every channel window's /quit reuse the exact same logic instead of duplicating it. + // Full shutdown for the "Exit wIRC" button: disconnect every server (which also writes + // all scrollback to disk), then end the process. A bare /quit in a channel window only + // disconnects that window's server - see ConnectionManager.disconnect. val shutdownApp: () -> Unit = { ConnectionManager.disconnectAll() exitApplication() @@ -122,8 +122,10 @@ fun main() = application { colorScheme = neutralDarkScheme, onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey }, onCloseSelf = { openBuffers = openBuffers - bufferKey }, - onQuitApp = { - exitApplication() + // openBuffers is read when /quit runs, so every window open at that moment + // gets its "Disconnected" line, even one that hasn't had a line yet. + onDisconnectServer = { + ConnectionManager.disconnect(bufferKey.serverId, openBuffers) } ) } diff --git a/src/main/kotlin/data/AliasRepository.kt b/src/main/kotlin/data/AliasRepository.kt index 8cc7684..664c07e 100644 --- a/src/main/kotlin/data/AliasRepository.kt +++ b/src/main/kotlin/data/AliasRepository.kt @@ -50,6 +50,12 @@ object AliasRepository { return aliases.size } + /** True if [command] (without the leading '/') is an alias - those run locally, no server needed. */ + fun hasAlias(command: String): Boolean { + val key = command.lowercase() + return key != RESERVED_RELOAD && key in aliases + } + /** * Returns true if [command] (without the leading '/') matched a known alias and its * action was run - callers should treat that as "handled, don't fall through to IRC diff --git a/src/main/kotlin/irc/ConnectionManager.kt b/src/main/kotlin/irc/ConnectionManager.kt index 152bdb6..d3c3542 100644 --- a/src/main/kotlin/irc/ConnectionManager.kt +++ b/src/main/kotlin/irc/ConnectionManager.kt @@ -73,6 +73,17 @@ object ConnectionManager { private val _ownNicks = MutableStateFlow>(emptyMap()) val ownNicksFlow: StateFlow> = _ownNicks + // Servers that have a live connection (or one that's connecting/auto-reconnecting after + // a network drop). A server leaves this set only through disconnect()/disconnectAll(), + // and it's what wIRC main's server cards show as Connected / Disconnected. + private val _connectedIds = MutableStateFlow>(emptySet()) + val connectedIds: StateFlow> = _connectedIds + + // serverId -> channels we were in when /quit disconnected that server, so the next + // connect() rejoins them (and the windows still open for them pick right back up) + // instead of only the configured auto-join list. + private val channelsToRejoin = ConcurrentHashMap>() + // A /whois we sent and haven't seen the end of yet. headerShown tracks whether its // "WHOIS " header line is already in wIRC main. private class PendingWhois(val nick: String, val sentAtMillis: Long, var headerShown: Boolean) @@ -142,33 +153,100 @@ object ConnectionManager { existing.disconnect() } setOwnNick(config.id, config.nick) - val conn = IrcConnection( + // Channels from the connection a /quit ended, or from the one being replaced, so + // they're rejoined along with the auto-join list. + val rejoin = channelsToRejoin.remove(config.id).orEmpty() + existing?.trackedChannels().orEmpty() + lateinit var conn: IrcConnection + conn = IrcConnection( config = config, scope = scope, - onLine = { line -> enqueue { handleLine(config.id, line) } }, + initialChannels = rejoin, + // Lines and status updates from a connection that's already been disconnected + // or replaced (its socket closing, a last line in flight) are dropped, so + // nothing shows up after the "Disconnected" line or overwrites that status. + onLine = { line -> if (connections[config.id] === conn) enqueue { handleLine(config.id, line) } }, onStatus = { msg -> - _statuses.update { it + (config.id to msg) } - // Connection progress and errors aren't tied to any channel - wIRC main. - enqueue { addMainLine(MessageKind.SYSTEM, null, "[${config.label}] $msg") } + if (connections[config.id] === conn) { + _statuses.update { it + (config.id to msg) } + // Connection progress and errors aren't tied to any channel - wIRC main. + enqueue { addMainLine(MessageKind.SYSTEM, null, "[${config.label}] $msg") } + } } ) connections[config.id] = conn + _connectedIds.update { it + config.id } conn.connect() } + /** + * Bare /quit: disconnects this one server and nothing else. Windows stay open and keep + * their scrollback; every buffer of this server (plus [openKeys], the open windows, in + * case one hasn't had a line yet) gets a "Disconnected" line, and so does wIRC main. + * There's no auto-reconnect afterward - the next connect() (the server card's Connect + * button) rejoins the channels we were in. History is written to disk right away, the + * same as disconnectAll() does on exit. + */ + fun disconnect(serverId: String, openKeys: Collection = emptyList()) { + val conn = connections.remove(serverId) ?: return + conn.disconnect() + channelsToRejoin[serverId] = conn.trackedChannels().toMutableSet() + _connectedIds.update { it - serverId } + _statuses.update { it + (serverId to "Disconnected") } + val label = conn.config.label + enqueue { + pendingWhois.remove(serverId) + val now = System.currentTimeMillis() + val keys = _buffers.value.keys.filter { it.serverId == serverId } + + openKeys.filter { it.serverId == serverId } + keys.distinct().forEach { key -> + addMessage(key, IrcMessage(now, MessageKind.SYSTEM, null, "Disconnected from $label")) + } + // We're no longer in any channel, so the user lists are stale - the NAMES reply + // on rejoin fills them again. + _buffers.update { map -> + map.mapValues { (key, buf) -> if (key.serverId == serverId) buf.copy(users = emptyList()) else buf } + } + addMainLine(MessageKind.SYSTEM, null, "[$label] Disconnected") + // Runs on the bufferOps queue after the lines above, so they're in the save. + flushAllSaves() + } + } + fun disconnectAll() { connections.values.forEach { it.disconnect() } connections.clear() + _connectedIds.value = emptySet() flushAllSaves() } + fun isConnected(serverId: String): Boolean = connections.containsKey(serverId) + + // /part from a channel window. While disconnected there's no PART to send, but the + // channel is dropped from the rejoin list so reconnecting doesn't rejoin it. + fun partChannel(serverId: String, channel: String, reason: String) { + val conn = connectionFor(serverId) + if (conn != null) conn.partChannel(channel, reason) + else channelsToRejoin[serverId]?.remove(channel) + } + + // /join from a channel window. A plain channel list is tracked (see + // IrcConnection.joinChannel) so a reconnect rejoins it; one with a key is sent as-is, + // since rejoining it later without the key wouldn't work anyway. + fun joinChannels(serverId: String, args: String) { + val conn = connectionFor(serverId) ?: return + if (args.contains(' ')) conn.send("JOIN $args") + else args.split(',').filter { it.isNotBlank() }.forEach { conn.joinChannel(it) } + } + fun connectionFor(serverId: String): IrcConnection? = connections[serverId] // IRC servers don't echo PRIVMSG back to its sender, so a sent message has to be // added to our own buffer locally - otherwise it would never show up in the window // that sent it. fun sendChannelMessage(serverId: String, target: String, text: String) { - connectionFor(serverId)?.sendMessage(target, text) + // No local echo while disconnected - the line was never sent. + val conn = connectionFor(serverId) ?: return + conn.sendMessage(target, text) val myNick = ownNicks[serverId] ?: return val msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text) enqueue { addOutgoing(serverId, target, msg, "-> *$target* $text") } @@ -178,7 +256,8 @@ object ConnectionManager { // CTCP ACTION wrapper, while the buffer should just hold the plain action text // (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix). fun sendChannelAction(serverId: String, target: String, action: String) { - connectionFor(serverId)?.sendMessage(target, "\u0001ACTION $action\u0001") + val conn = connectionFor(serverId) ?: return + conn.sendMessage(target, "\u0001ACTION $action\u0001") val myNick = ownNicks[serverId] ?: return val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action) enqueue { addOutgoing(serverId, target, msg, "-> *$target* * $myNick $action") } diff --git a/src/main/kotlin/irc/IrcConnection.kt b/src/main/kotlin/irc/IrcConnection.kt index 53edbf7..94b972b 100644 --- a/src/main/kotlin/irc/IrcConnection.kt +++ b/src/main/kotlin/irc/IrcConnection.kt @@ -50,6 +50,9 @@ private fun translateOnConnectCommand(cmd: String): String { 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 = emptyList(), private val onLine: (ParsedLine) -> Unit, private val onStatus: (String) -> Unit ) { @@ -60,13 +63,17 @@ class IrcConnection( @Volatile private var socket: Socket? = null @Volatile private var writer: PrintWriter? = null private var readJob: Job? = null - private var wantConnected = false + // @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() + private val joinedChannels = java.util.concurrent.CopyOnWriteArraySet(initialChannels) + + fun trackedChannels(): Set = 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 @@ -121,6 +128,9 @@ class IrcConnection( 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 @@ -161,6 +171,9 @@ class IrcConnection( 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 { @@ -277,6 +290,9 @@ class IrcConnection( 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() } } diff --git a/src/main/kotlin/ui/ChannelWindow.kt b/src/main/kotlin/ui/ChannelWindow.kt index e04a98e..05cc26c 100644 --- a/src/main/kotlin/ui/ChannelWindow.kt +++ b/src/main/kotlin/ui/ChannelWindow.kt @@ -20,9 +20,20 @@ import data.WindowPrefsRepository import data.isChannelName import data.storageId import irc.ConnectionManager +import irc.IrcConnection import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +private const val NOT_CONNECTED_TEXT = "Not connected - use Connect in wIRC main to reconnect" + +// Runs [action] on this server's connection, or shows the not-connected line in this +// window when there isn't one (e.g. the user-list kick/ban/op menu after a /quit). +private fun withConnection(serverId: String, windowName: String, action: (IrcConnection) -> Unit) { + val conn = ConnectionManager.connectionFor(serverId) + if (conn != null) action(conn) + else ConnectionManager.addSystemMessage(serverId, windowName, NOT_CONNECTED_TEXT) +} + /** * One of these is composed per open buffer key (see Main.kt) - each is a real, independent * top-level OS window, same intent as ChannelWindowActivity.kt's per-channel Activity on @@ -36,7 +47,7 @@ fun ChannelWindow( colorScheme: ColorScheme, onOpenBuffer: (BufferKey) -> Unit, onCloseSelf: () -> Unit, - onQuitApp: () -> Unit + onDisconnectServer: () -> Unit ) { val serverId = bufferKey.serverId val channelName = bufferKey.name @@ -87,7 +98,19 @@ fun ChannelWindow( displaySettings = displaySettings, colorScheme = colorScheme, ownNick = ownNick, - onSend = { text -> + onSend = onSend@{ text -> + // Everything except /reload, aliases and /part (which only closes + // the window) needs the server - while disconnected it gets this + // line in this window instead of going nowhere. + val needsServer = if (text.startsWith("/")) { + val command = text.substring(1).substringBefore(' ').uppercase() + command != "RELOAD" && command !in setOf("PART", "EXIT", "CLOSE") && + !AliasRepository.hasAlias(command) + } else true + if (needsServer && !ConnectionManager.isConnected(serverId)) { + ConnectionManager.addSystemMessage(serverId, channelName, NOT_CONNECTED_TEXT) + return@onSend + } if (text.startsWith("/")) { val withoutSlash = text.substring(1) val spaceIdx = withoutSlash.indexOf(' ') @@ -112,19 +135,20 @@ fun ChannelWindow( "NICK" -> ConnectionManager.sendRaw(serverId, "NICK $args") "PART", "EXIT", "CLOSE" -> { val target = args.ifBlank { channelName } - ConnectionManager.connectionFor(serverId)?.partChannel(target, "leaving") + ConnectionManager.partChannel(serverId, target, "leaving") if (args.isBlank() || target == channelName) onCloseSelf() } "QUIT" -> { if (rawArgs.isBlank()) { - ConnectionManager.disconnectAll() - onQuitApp() + // Disconnects this window's server only - every + // window stays open; see ConnectionManager.disconnect. + onDisconnectServer() } else { ConnectionManager.sendChannelMessage(serverId, channelName, rawArgs) } } "JOIN" -> { - ConnectionManager.sendRaw(serverId, "JOIN $args") + ConnectionManager.joinChannels(serverId, args) onOpenBuffer(BufferKey(serverId, args)) } // All whois output goes to wIRC main, not this window. @@ -144,10 +168,10 @@ fun ChannelWindow( ConnectionManager.sendChannelMessage(serverId, channelName, text) } }, - onKick = { nick -> ConnectionManager.connectionFor(serverId)?.kick(channelName, nick) }, - onBan = { nick -> ConnectionManager.connectionFor(serverId)?.ban(channelName, "$nick!*@*") }, - onOp = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "+o", nick) }, - onDeop = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "-o", nick) }, + onKick = { nick -> withConnection(serverId, channelName) { it.kick(channelName, nick) } }, + onBan = { nick -> withConnection(serverId, channelName) { it.ban(channelName, "$nick!*@*") } }, + onOp = { nick -> withConnection(serverId, channelName) { it.setMode(channelName, "+o", nick) } }, + onDeop = { nick -> withConnection(serverId, channelName) { it.setMode(channelName, "-o", nick) } }, onAlphaChange = { alpha -> scope.launch(Dispatchers.IO) { prefsRepo.setAlpha(bufferKey.storageId(), alpha) } }, diff --git a/src/main/kotlin/ui/MainScreen.kt b/src/main/kotlin/ui/MainScreen.kt index 1f8f761..7b5a449 100644 --- a/src/main/kotlin/ui/MainScreen.kt +++ b/src/main/kotlin/ui/MainScreen.kt @@ -48,7 +48,9 @@ fun MainScreen( var servers by remember { mutableStateOf(repo.loadAll()) } var showAddDialog by remember { mutableStateOf(false) } var editingServer by remember { mutableStateOf(null) } - var connectedIds by remember { mutableStateOf(setOf()) } + // From ConnectionManager rather than local state, so a /quit typed in a channel window + // flips that server's card back to Disconnected with its Connect button. + val connectedIds by ConnectionManager.connectedIds.collectAsState() var channelInputs by remember { mutableStateOf(mapOf()) } var openedChannels by remember { mutableStateOf(mapOf>()) } val coroutineScope = rememberCoroutineScope() @@ -85,9 +87,8 @@ fun MainScreen( Text("+ Add Server") } Spacer(Modifier.width(8.dp)) - // Same end result as typing "/quit" alone in a channel window - disconnects - // every server and terminates the process - just reachable without needing - // a channel window open first. + // Disconnects every server and terminates the process. (A bare "/quit" in a + // channel window only disconnects that window's server.) TextButton(onClick = onExitApp) { Text("Exit wIRC") } @@ -108,8 +109,9 @@ fun MainScreen( openedChannels = openedChannels[server.id] ?: emptyList(), onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) }, onConnect = { + // Also rejoins the channels this server was in before a /quit - their + // windows are still open, and opening one that already is is a no-op. ConnectionManager.connect(server) - connectedIds = connectedIds + server.id if (server.autoJoinChannels.isNotEmpty()) { // Auto-join sends JOIN correctly on connect (see IrcConnection), // but nothing else opens a window for it like the manual @@ -243,6 +245,8 @@ private fun ServerCard( Spacer(Modifier.height(8.dp)) if (!isConnected) { + Text("Disconnected", style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(4.dp)) Button(onClick = onConnect) { Text("Connect") } } else { Text("Connected", style = MaterialTheme.typography.bodySmall)