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:
co-authored by
Claude Opus 5.5
parent
d9a0297cc7
commit
3538274805
+2
-2
@@ -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"
|
||||
|
||||
@@ -58,9 +58,9 @@ fun main() = application {
|
||||
var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -73,6 +73,17 @@ object ConnectionManager {
|
||||
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
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
|
||||
// "WHOIS <nick>" 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<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() {
|
||||
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") }
|
||||
|
||||
@@ -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<String> = 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<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
|
||||
// (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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
},
|
||||
|
||||
@@ -48,7 +48,9 @@ fun MainScreen(
|
||||
var servers by remember { mutableStateOf(repo.loadAll()) }
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
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 openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) }
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user