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 <noreply@anthropic.com>
This commit is contained in:
weishaupt
2026-10-06 12:46:59 -04:00
co-authored by Claude Opus 5.5
parent d9a0297cc7
commit 3538274805
7 changed files with 162 additions and 31 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ plugins {
} }
group = "com.tpuahsiew.wirc" group = "com.tpuahsiew.wirc"
version = "0.1.8" version = "0.1.9"
// No project-level repositories{} block here: settings.gradle.kts sets // No project-level repositories{} block here: settings.gradle.kts sets
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already // dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
@@ -38,7 +38,7 @@ compose.desktop {
appResourcesRootDir.set(project.file("installerResources")) appResourcesRootDir.set(project.file("installerResources"))
targetFormats(TargetFormat.Msi, TargetFormat.Exe) targetFormats(TargetFormat.Msi, TargetFormat.Exe)
packageName = "wIRC" packageName = "wIRC"
packageVersion = "0.1.8" packageVersion = "0.1.9"
windows { windows {
menuGroup = "wIRC" menuGroup = "wIRC"
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a" upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
+7 -5
View File
@@ -58,9 +58,9 @@ fun main() = application {
var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) } var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
var mainUnread by remember { mutableStateOf(0) } var mainUnread by remember { mutableStateOf(0) }
// Full shutdown: same steps /quit (bare, no args) already runs from a channel window - // Full shutdown for the "Exit wIRC" button: disconnect every server (which also writes
// disconnect every server, then end the process. Shared here so the "Exit wIRC" button // all scrollback to disk), then end the process. A bare /quit in a channel window only
// and every channel window's /quit reuse the exact same logic instead of duplicating it. // disconnects that window's server - see ConnectionManager.disconnect.
val shutdownApp: () -> Unit = { val shutdownApp: () -> Unit = {
ConnectionManager.disconnectAll() ConnectionManager.disconnectAll()
exitApplication() exitApplication()
@@ -122,8 +122,10 @@ fun main() = application {
colorScheme = neutralDarkScheme, colorScheme = neutralDarkScheme,
onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey }, onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey },
onCloseSelf = { openBuffers = openBuffers - bufferKey }, onCloseSelf = { openBuffers = openBuffers - bufferKey },
onQuitApp = { // openBuffers is read when /quit runs, so every window open at that moment
exitApplication() // gets its "Disconnected" line, even one that hasn't had a line yet.
onDisconnectServer = {
ConnectionManager.disconnect(bufferKey.serverId, openBuffers)
} }
) )
} }
+6
View File
@@ -50,6 +50,12 @@ object AliasRepository {
return aliases.size 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 * 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 * action was run - callers should treat that as "handled, don't fall through to IRC
+86 -7
View File
@@ -73,6 +73,17 @@ object ConnectionManager {
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap()) private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks val ownNicksFlow: StateFlow<Map<String, String>> = _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<Set<String>>(emptySet())
val connectedIds: StateFlow<Set<String>> = _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<String, MutableSet<String>>()
// A /whois we sent and haven't seen the end of yet. headerShown tracks whether its // A /whois we sent and haven't seen the end of yet. headerShown tracks whether its
// "WHOIS <nick>" header line is already in wIRC main. // "WHOIS <nick>" header line is already in wIRC main.
private class PendingWhois(val nick: String, val sentAtMillis: Long, var headerShown: Boolean) private class PendingWhois(val nick: String, val sentAtMillis: Long, var headerShown: Boolean)
@@ -142,33 +153,100 @@ object ConnectionManager {
existing.disconnect() existing.disconnect()
} }
setOwnNick(config.id, config.nick) 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, config = config,
scope = scope, 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 -> onStatus = { msg ->
_statuses.update { it + (config.id to msg) } if (connections[config.id] === conn) {
// Connection progress and errors aren't tied to any channel - wIRC main. _statuses.update { it + (config.id to msg) }
enqueue { addMainLine(MessageKind.SYSTEM, null, "[${config.label}] $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 connections[config.id] = conn
_connectedIds.update { it + config.id }
conn.connect() 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<BufferKey> = 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() { fun disconnectAll() {
connections.values.forEach { it.disconnect() } connections.values.forEach { it.disconnect() }
connections.clear() connections.clear()
_connectedIds.value = emptySet()
flushAllSaves() 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] fun connectionFor(serverId: String): IrcConnection? = connections[serverId]
// IRC servers don't echo PRIVMSG back to its sender, so a sent message has to be // 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 // added to our own buffer locally - otherwise it would never show up in the window
// that sent it. // that sent it.
fun sendChannelMessage(serverId: String, target: String, text: String) { 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 myNick = ownNicks[serverId] ?: return
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text) val msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text)
enqueue { addOutgoing(serverId, target, msg, "-> *$target* $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 // CTCP ACTION wrapper, while the buffer should just hold the plain action text
// (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix). // (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix).
fun sendChannelAction(serverId: String, target: String, action: String) { 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 myNick = ownNicks[serverId] ?: return
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action) val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action)
enqueue { addOutgoing(serverId, target, msg, "-> *$target* * $myNick $action") } enqueue { addOutgoing(serverId, target, msg, "-> *$target* * $myNick $action") }
+18 -2
View File
@@ -50,6 +50,9 @@ private fun translateOnConnectCommand(cmd: String): String {
class IrcConnection( class IrcConnection(
val config: ServerConfig, val config: ServerConfig,
private val scope: CoroutineScope, 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 onLine: (ParsedLine) -> Unit,
private val onStatus: (String) -> Unit private val onStatus: (String) -> Unit
) { ) {
@@ -60,13 +63,17 @@ class IrcConnection(
@Volatile private var socket: Socket? = null @Volatile private var socket: Socket? = null
@Volatile private var writer: PrintWriter? = null @Volatile private var writer: PrintWriter? = null
private var readJob: Job? = 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 private var backoffMillis = 2000L
// Every channel we're currently in, whether from config.autoJoinChannels or joined // Every channel we're currently in, whether from config.autoJoinChannels or joined
// manually mid-session - so a reconnect rejoins everything, not just the original // manually mid-session - so a reconnect rejoins everything, not just the original
// auto-join list. // auto-join list.
private val joinedChannels = java.util.concurrent.CopyOnWriteArraySet<String>() 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 // Guards runPostRegistration() against running twice for the same connection cycle
// (376 and 422 are mutually exclusive per RFC, but this is cheap insurance) - reset // (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(config.host, config.port)
} }
socket = sock 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) val out = PrintWriter(OutputStreamWriter(sock.getOutputStream(), Charsets.UTF_8), true)
writer = out writer = out
@@ -161,6 +171,9 @@ class IrcConnection(
handleRawLine(line) handleRawLine(line)
} }
} catch (e: Exception) { } 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}") onStatus("Connection error: ${e.message}")
ircDebug(config.id, "connection dropped: ${e.message} - tracked channels at drop time: $joinedChannels") ircDebug(config.id, "connection dropped: ${e.message} - tracked channels at drop time: $joinedChannels")
} finally { } finally {
@@ -277,6 +290,9 @@ class IrcConnection(
try { writer?.println("QUIT :leaving") } catch (_: Exception) {} try { writer?.println("QUIT :leaving") } catch (_: Exception) {}
readJob?.cancel() readJob?.cancel()
closeSocketQuietly() closeSocketQuietly()
// Ends the outbox consumer - this connection is never reused (a reconnect
// makes a new IrcConnection), so it would otherwise idle forever.
outbox.close()
} }
} }
+34 -10
View File
@@ -20,9 +20,20 @@ import data.WindowPrefsRepository
import data.isChannelName import data.isChannelName
import data.storageId import data.storageId
import irc.ConnectionManager import irc.ConnectionManager
import irc.IrcConnection
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch 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 * 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 * top-level OS window, same intent as ChannelWindowActivity.kt's per-channel Activity on
@@ -36,7 +47,7 @@ fun ChannelWindow(
colorScheme: ColorScheme, colorScheme: ColorScheme,
onOpenBuffer: (BufferKey) -> Unit, onOpenBuffer: (BufferKey) -> Unit,
onCloseSelf: () -> Unit, onCloseSelf: () -> Unit,
onQuitApp: () -> Unit onDisconnectServer: () -> Unit
) { ) {
val serverId = bufferKey.serverId val serverId = bufferKey.serverId
val channelName = bufferKey.name val channelName = bufferKey.name
@@ -87,7 +98,19 @@ fun ChannelWindow(
displaySettings = displaySettings, displaySettings = displaySettings,
colorScheme = colorScheme, colorScheme = colorScheme,
ownNick = ownNick, 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("/")) { if (text.startsWith("/")) {
val withoutSlash = text.substring(1) val withoutSlash = text.substring(1)
val spaceIdx = withoutSlash.indexOf(' ') val spaceIdx = withoutSlash.indexOf(' ')
@@ -112,19 +135,20 @@ fun ChannelWindow(
"NICK" -> ConnectionManager.sendRaw(serverId, "NICK $args") "NICK" -> ConnectionManager.sendRaw(serverId, "NICK $args")
"PART", "EXIT", "CLOSE" -> { "PART", "EXIT", "CLOSE" -> {
val target = args.ifBlank { channelName } val target = args.ifBlank { channelName }
ConnectionManager.connectionFor(serverId)?.partChannel(target, "leaving") ConnectionManager.partChannel(serverId, target, "leaving")
if (args.isBlank() || target == channelName) onCloseSelf() if (args.isBlank() || target == channelName) onCloseSelf()
} }
"QUIT" -> { "QUIT" -> {
if (rawArgs.isBlank()) { if (rawArgs.isBlank()) {
ConnectionManager.disconnectAll() // Disconnects this window's server only - every
onQuitApp() // window stays open; see ConnectionManager.disconnect.
onDisconnectServer()
} else { } else {
ConnectionManager.sendChannelMessage(serverId, channelName, rawArgs) ConnectionManager.sendChannelMessage(serverId, channelName, rawArgs)
} }
} }
"JOIN" -> { "JOIN" -> {
ConnectionManager.sendRaw(serverId, "JOIN $args") ConnectionManager.joinChannels(serverId, args)
onOpenBuffer(BufferKey(serverId, args)) onOpenBuffer(BufferKey(serverId, args))
} }
// All whois output goes to wIRC main, not this window. // All whois output goes to wIRC main, not this window.
@@ -144,10 +168,10 @@ fun ChannelWindow(
ConnectionManager.sendChannelMessage(serverId, channelName, text) ConnectionManager.sendChannelMessage(serverId, channelName, text)
} }
}, },
onKick = { nick -> ConnectionManager.connectionFor(serverId)?.kick(channelName, nick) }, onKick = { nick -> withConnection(serverId, channelName) { it.kick(channelName, nick) } },
onBan = { nick -> ConnectionManager.connectionFor(serverId)?.ban(channelName, "$nick!*@*") }, onBan = { nick -> withConnection(serverId, channelName) { it.ban(channelName, "$nick!*@*") } },
onOp = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "+o", nick) }, onOp = { nick -> withConnection(serverId, channelName) { it.setMode(channelName, "+o", nick) } },
onDeop = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "-o", nick) }, onDeop = { nick -> withConnection(serverId, channelName) { it.setMode(channelName, "-o", nick) } },
onAlphaChange = { alpha -> onAlphaChange = { alpha ->
scope.launch(Dispatchers.IO) { prefsRepo.setAlpha(bufferKey.storageId(), alpha) } scope.launch(Dispatchers.IO) { prefsRepo.setAlpha(bufferKey.storageId(), alpha) }
}, },
+9 -5
View File
@@ -48,7 +48,9 @@ fun MainScreen(
var servers by remember { mutableStateOf(repo.loadAll()) } var servers by remember { mutableStateOf(repo.loadAll()) }
var showAddDialog by remember { mutableStateOf(false) } var showAddDialog by remember { mutableStateOf(false) }
var editingServer by remember { mutableStateOf<ServerConfig?>(null) } var editingServer by remember { mutableStateOf<ServerConfig?>(null) }
var connectedIds by remember { mutableStateOf(setOf<String>()) } // 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<String, String>()) } var channelInputs by remember { mutableStateOf(mapOf<String, String>()) }
var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) } var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -85,9 +87,8 @@ fun MainScreen(
Text("+ Add Server") Text("+ Add Server")
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
// Same end result as typing "/quit" alone in a channel window - disconnects // Disconnects every server and terminates the process. (A bare "/quit" in a
// every server and terminates the process - just reachable without needing // channel window only disconnects that window's server.)
// a channel window open first.
TextButton(onClick = onExitApp) { TextButton(onClick = onExitApp) {
Text("Exit wIRC") Text("Exit wIRC")
} }
@@ -108,8 +109,9 @@ fun MainScreen(
openedChannels = openedChannels[server.id] ?: emptyList(), openedChannels = openedChannels[server.id] ?: emptyList(),
onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) }, onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) },
onConnect = { 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) ConnectionManager.connect(server)
connectedIds = connectedIds + server.id
if (server.autoJoinChannels.isNotEmpty()) { if (server.autoJoinChannels.isNotEmpty()) {
// Auto-join sends JOIN correctly on connect (see IrcConnection), // Auto-join sends JOIN correctly on connect (see IrcConnection),
// but nothing else opens a window for it like the manual // but nothing else opens a window for it like the manual
@@ -243,6 +245,8 @@ private fun ServerCard(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
if (!isConnected) { if (!isConnected) {
Text("Disconnected", style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(4.dp))
Button(onClick = onConnect) { Text("Connect") } Button(onClick = onConnect) { Text("Connect") }
} else { } else {
Text("Connected", style = MaterialTheme.typography.bodySmall) Text("Connected", style = MaterialTheme.typography.bodySmall)