Add wIRC main scrollback, route services to it, remember window bounds, bump to 0.1.7

wIRC main scrollback
- The main window now has a "Server messages" scrollback using the same
  rendering and stick-to-bottom code as channel windows (extracted from
  ChannelScreen into a shared MessageScrollback composable).
- MOTD, numerics, errors, connection status, user modes, INVITE, ERROR,
  non-ACTION CTCP requests and notices/messages from services
  (NickServ, ChanServ, *Serv, Global, ...) and the server go there. Its
  history persists like any other buffer (history/wirc-main__main.json).
- Unread indicator: lines arriving while the main window is unfocused
  show as "N new" next to the label and in the window title/taskbar.

Private messages and /whois
- A PM or notice from a real user opens its query window, and reopens it
  if it was closed. Services and the server no longer open windows.
- /whois <nick> sends WHOIS and shows the formatted reply in the window
  it was typed in, or in wIRC main if that window has since closed.
- /me now sends a proper CTCP ACTION (the \x01 wrapper was missing).

Window position and size
- Main, channel and PM windows remember position and size in
  window_prefs.json under the existing "<serverId>__<name>" key (main
  uses "wirc-main__main"). The new windowX/windowY/windowWidth/
  windowHeight fields are optional, so older files still load.
- A saved position is only used if the window's title strip is on a
  connected monitor; otherwise, and for windows with no saved position,
  windows cascade from the primary screen's top-left with a 32dp offset.
- window_prefs.json is now written via a temp file + atomic move.

Cleanup
- Remove the TEMP DEBUG aliasDebug tracing, ServerRepository.upsert
  printlns and connmgr-debug printlns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
weishaupt
2026-10-05 15:08:38 -04:00
co-authored by Claude Opus 5.5
parent 242d7a7c42
commit 807903fe0f
11 changed files with 866 additions and 389 deletions
+193 -47
View File
@@ -14,6 +14,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
// Per-buffer message history cap. Named so it's a one-line change to temporarily lower
@@ -28,6 +31,19 @@ private const val MESSAGE_HISTORY_CAP = 500
// reported slowness under real message volume.
private const val SAVE_DEBOUNCE_MS = 2500L
// WHOIS reply numerics. Each one's params are [ourNick, targetNick, ...], so the target
// nick (params[1]) is what ties a reply line back to the /whois that asked for it.
// 318 is RPL_ENDOFWHOIS; 401/402 are the "no such nick/server" errors a WHOIS can get.
private val WHOIS_NUMERICS = setOf(
"275", "276", "301", "307", "310", "311", "312", "313", "317", "318", "319",
"320", "330", "335", "338", "378", "379", "401", "402", "671"
)
// Numerics that only make sense inside a channel window and are already reflected there
// (353 fills the user list, see handleLine) or carry nothing worth showing on their own -
// kept out of wIRC main so joining a channel doesn't flood it.
private val CHANNEL_ONLY_NUMERICS = setOf("332", "333", "353", "366")
/**
* App-wide singleton. Owns every IrcConnection and every ChannelBuffer, so that all
* windows read from the same source of truth via StateFlow.
@@ -47,8 +63,19 @@ object ConnectionManager {
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
// Buffers that currently have a window open, kept up to date by Main.kt. Read when a
// WHOIS reply arrives, to decide whether the window that asked is still there to show it.
@Volatile
var openWindows: Set<BufferKey> = emptySet()
// serverId -> (lowercased target nick -> the buffer whose window typed the /whois).
private val pendingWhois = ConcurrentHashMap<String, ConcurrentHashMap<String, BufferKey>>()
fun init(rootDir: File) {
historyRepo = MessageHistoryRepository(rootDir)
// Seeds wIRC main from its saved history right away, so its scrollback is there at
// startup instead of appearing only once the first server line arrives.
enqueue { ensureBuffer(MAIN_BUFFER_KEY) }
}
private fun setOwnNick(serverId: String, nick: String) {
@@ -56,11 +83,12 @@ object ConnectionManager {
_ownNicks.update { it + (serverId to nick) }
}
// Fires once for every brand-new private-message buffer (a query with someone
// who wasn't already an open window). Main.kt listens to this to auto-open a
// window for it, same as clicking a channel manually.
private val _newQueryBuffers = MutableSharedFlow<BufferKey>(extraBufferCapacity = 16)
val newQueryBuffers: SharedFlow<BufferKey> = _newQueryBuffers
// Fires for every private message to or from a real user (services and the server go
// to wIRC main instead). Main.kt adds the key to its set of open windows, which opens
// the query window if it isn't open - including reopening one the user closed - and
// is a no-op if it already is.
private val _queryWindowRequests = MutableSharedFlow<BufferKey>(extraBufferCapacity = 64)
val queryWindowRequests: SharedFlow<BufferKey> = _queryWindowRequests
// Every buffer state change - incoming lines, local echoes of our own sends - goes
// through this single FIFO queue, processed by one consumer coroutine. Previously each
@@ -92,10 +120,6 @@ object ConnectionManager {
}
fun connect(config: ServerConfig) {
// TEMP DEBUG (see task: on-connect identify command investigation) - confirms
// whether the config this connect() call actually received has the on-connect
// commands the caller thinks it saved.
println("[connmgr-debug] connect(${config.id}) called - onConnectCommands=${config.onConnectCommands}")
val existing = connections[config.id]
if (existing != null) {
// This used to just `return` here, making connect() a permanent no-op for the
@@ -105,7 +129,6 @@ object ConnectionManager {
// `config` was captured once at construction and never replaced. Tearing down
// the old connection and replacing it means a connect() call always actually
// uses whatever was most recently saved.
println("[connmgr-debug] connect(${config.id}) - already connected, replacing with a fresh connection using the latest config")
existing.disconnect()
}
setOwnNick(config.id, config.nick)
@@ -113,7 +136,11 @@ object ConnectionManager {
config = config,
scope = scope,
onLine = { line -> enqueue { handleLine(config.id, line) } },
onStatus = { msg -> _statuses.update { it + (config.id to msg) } }
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") }
}
)
connections[config.id] = conn
conn.connect()
@@ -133,20 +160,32 @@ object ConnectionManager {
fun sendChannelMessage(serverId: String, target: String, text: String) {
connectionFor(serverId)?.sendMessage(target, text)
val myNick = ownNicks[serverId] ?: return
val key = BufferKey(serverId, target)
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text)
enqueue { addMessage(key, msg) }
enqueue { addOutgoing(serverId, target, msg, "-> *$target* $text") }
}
// Same local-echo problem as sendChannelMessage, but for /me: the wire form is a
// 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, "ACTION $action")
connectionFor(serverId)?.sendMessage(target, "\u0001ACTION $action\u0001")
val myNick = ownNicks[serverId] ?: return
val key = BufferKey(serverId, target)
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action)
enqueue { addMessage(key, msg) }
enqueue { addOutgoing(serverId, target, msg, "-> *$target* * $myNick $action") }
}
// Where a local echo goes: a channel's own buffer, wIRC main for a service (e.g.
// "/msg NickServ ..."), or a query buffer - opening its window - for a real user.
private fun addOutgoing(serverId: String, target: String, msg: IrcMessage, mainText: String) {
when {
isChannelName(target) -> addMessage(BufferKey(serverId, target), msg)
isServiceNick(target) -> addMainLine(MessageKind.SYSTEM, null, mainText)
else -> {
val key = BufferKey(serverId, target)
addMessage(key, msg)
_queryWindowRequests.tryEmit(key)
}
}
}
// Raw passthrough for slash commands that don't have dedicated handling.
@@ -154,6 +193,22 @@ object ConnectionManager {
connectionFor(serverId)?.send(raw)
}
/**
* Sends WHOIS and remembers which window asked, so the reply lines are shown in that
* window - or in wIRC main if it has been closed by the time they arrive. [args] is
* whatever followed "/whois"; its last word is the nick being looked up (the
* "/whois server nick" form puts the nick last too).
*/
fun whois(serverId: String, args: String, origin: BufferKey) {
val nick = args.trim().split(Regex("\\s+")).lastOrNull()?.takeIf { it.isNotEmpty() }
if (nick == null) {
addSystemMessage(serverId, origin.name, "Usage: /whois <nick>")
return
}
pendingWhois.getOrPut(serverId) { ConcurrentHashMap() }[nick.lowercase()] = origin
sendRaw(serverId, "WHOIS ${args.trim()}")
}
// Local-only feedback (e.g. the /reload confirmation) that never touches the
// wire - just appended to the buffer the same way any other SYSTEM line is.
fun addSystemMessage(serverId: String, target: String, text: String) {
@@ -177,12 +232,6 @@ object ConnectionManager {
if (_buffers.value.containsKey(key)) return
val seeded = historyRepo?.loadMessages(key) ?: emptyList()
_buffers.update { map -> map + (key to ChannelBuffer(key, messages = seeded)) }
if (!isChannelName(key.name)) {
// tryEmit, not emit: this runs on the single, non-suspend bufferOps consumer,
// not its own coroutine, so there's nothing to suspend into. The SharedFlow's
// 16-slot extraBufferCapacity means this can't actually fail in practice.
_newQueryBuffers.tryEmit(key)
}
}
private fun addMessage(key: BufferKey, msg: IrcMessage) {
@@ -195,6 +244,10 @@ object ConnectionManager {
scheduleSave(key)
}
private fun addMainLine(kind: MessageKind, sender: String?, text: String) {
addMessage(MAIN_BUFFER_KEY, IrcMessage(System.currentTimeMillis(), kind, sender, text))
}
// Coalesces rapid-fire saves for the same buffer into at most one write every
// SAVE_DEBOUNCE_MS, instead of a full history save (up to 500 messages) on every single
// incoming message - the direct cause of reported slowness in a busy channel. Reads
@@ -229,6 +282,57 @@ object ConnectionManager {
}
}
// A WHOIS reply line goes to the window that typed the /whois if it's still open,
// otherwise (closed since, or a WHOIS nobody asked for here) to wIRC main.
private fun handleWhoisLine(serverId: String, line: ParsedLine) {
val target = line.params.getOrNull(1) ?: return
val pending = pendingWhois[serverId]
val origin = pending?.get(target.lowercase())
if (line.command == "318") pending?.remove(target.lowercase())
val text = "[whois] " + formatWhoisLine(line)
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.SYSTEM, null, text)
if (origin != null && origin in openWindows) addMessage(origin, msg) else addMessage(MAIN_BUFFER_KEY, msg)
}
private fun formatWhoisLine(line: ParsedLine): String {
val p = line.params
val nick = p.getOrNull(1) ?: ""
return when (line.command) {
"311" -> "$nick is ${p.getOrNull(2)}@${p.getOrNull(3)} (${p.lastOrNull() ?: ""})"
"312" -> "$nick is on ${p.getOrNull(2)} (${p.lastOrNull() ?: ""})"
"317" -> {
val idleSecs = p.getOrNull(2)?.toLongOrNull()
val signon = p.getOrNull(3)?.toLongOrNull()
buildString {
append("$nick has been idle ${idleSecs?.let { formatDuration(it) } ?: "?"}")
if (signon != null) {
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
append(", signed on ${fmt.format(Date(signon * 1000))}")
}
}
}
"319" -> "$nick is on channels: ${p.lastOrNull() ?: ""}"
"330" -> "$nick ${p.lastOrNull() ?: "is logged in as"} ${p.getOrNull(2) ?: ""}".trim()
"301" -> "$nick is away: ${p.lastOrNull() ?: ""}"
"318" -> "End of WHOIS for $nick"
else -> p.drop(1).joinToString(" ")
}
}
private fun formatDuration(totalSecs: Long): String {
val d = totalSecs / 86400
val h = (totalSecs % 86400) / 3600
val m = (totalSecs % 3600) / 60
val s = totalSecs % 60
return buildString {
if (d > 0) append("${d}d ")
if (d > 0 || h > 0) append("${h}h ")
if (d > 0 || h > 0 || m > 0) append("${m}m ")
append("${s}s")
}
}
private fun handleLine(serverId: String, line: ParsedLine) {
val now = System.currentTimeMillis()
val nick = nickFromPrefix(line.prefix) ?: line.prefix
@@ -237,16 +341,46 @@ object ConnectionManager {
"PRIVMSG" -> {
val target = line.params.getOrNull(0) ?: return
val text = line.params.getOrNull(1) ?: return
val isAction = text.startsWith("ACTION") && text.endsWith("")
val body = if (isAction) text.removePrefix("ACTION ").removeSuffix("") else text
// Channel messages are keyed by the channel name. Private messages arrive
// with target == our own nick, so key those by the *sender* instead -
// that's the conversation identity a query window should use.
val bufferName = if (isChannelName(target)) target else (nick ?: target)
addMessage(
BufferKey(serverId, bufferName),
IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)
)
val isCtcp = text.length > 1 && text.startsWith("\u0001") && text.endsWith("\u0001")
val isAction = isCtcp && text.startsWith("\u0001ACTION")
val body = if (isAction) text.removePrefix("\u0001ACTION ").removeSuffix("\u0001") else text
if (isChannelName(target)) {
addMessage(
BufferKey(serverId, target),
IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)
)
} else if (isServiceOrServerPrefix(line.prefix) || (isCtcp && !isAction)) {
// Services, the server itself, and CTCP requests other than ACTION
// (PING, TIME, ...) go to wIRC main instead of opening a query window.
// (VERSION is already answered and swallowed in IrcConnection.)
val shown = if (isCtcp && !isAction) "[CTCP ${text.trim('\u0001')} from $nick]" else body
addMainLine(MessageKind.CHAT, nick, shown)
} else {
// A private message from a real user arrives with target == our own
// nick, so key it by the *sender* - that's the conversation identity a
// query window should use - and (re)open that window.
val key = BufferKey(serverId, nick ?: target)
addMessage(key, IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body))
_queryWindowRequests.tryEmit(key)
}
}
"NOTICE" -> {
val target = line.params.getOrNull(0) ?: return
val text = line.params.getOrNull(1) ?: return
if (isChannelName(target)) {
addMessage(BufferKey(serverId, target), IrcMessage(now, MessageKind.NOTICE, nick, text))
} else if (isServiceOrServerPrefix(line.prefix)) {
// NickServ/ChanServ replies (identify success/failure etc.) and server
// notices - wIRC main. The sender goes into the text because the
// NOTICE row layout doesn't show a nick column.
addMainLine(MessageKind.NOTICE, nick, "-${nick ?: "server"}- $text")
} else {
// A notice directed AT us arrives with target == our own nick, so key
// it by the sender, same as a private message.
val key = BufferKey(serverId, nick ?: target)
addMessage(key, IrcMessage(now, MessageKind.NOTICE, nick, text))
_queryWindowRequests.tryEmit(key)
}
}
"JOIN" -> {
val channel = line.params.getOrNull(0) ?: return
@@ -270,9 +404,13 @@ object ConnectionManager {
}
"MODE" -> {
val channel = line.params.getOrNull(0) ?: return
if (!channel.startsWith("#")) return
val modeStr = line.params.getOrNull(1) ?: ""
val modeTarget = line.params.getOrNull(2)
if (!isChannelName(channel)) {
// User mode on our own nick (e.g. +i, +r after identifying) - wIRC main.
addMainLine(MessageKind.MODE, nick, "$nick sets mode $modeStr on $channel")
return
}
addMessage(
BufferKey(serverId, channel),
IrcMessage(now, MessageKind.MODE, nick, "$nick sets mode $modeStr ${modeTarget ?: ""}".trim())
@@ -315,20 +453,6 @@ object ConnectionManager {
}
updateUsers(BufferKey(serverId, channel)) { existing -> (existing + parsedUsers).distinctBy { it.nick } }
}
"NOTICE" -> {
val target = line.params.getOrNull(0) ?: return
val text = line.params.getOrNull(1) ?: return
// Same fix PRIVMSG already had: a notice directed AT us arrives with
// target == our own nick, not the sender. This mattered a lot in practice -
// NickServ/ChanServ almost universally reply to commands (IDENTIFY, etc.)
// via NOTICE rather than PRIVMSG, so without this fallback every identify
// success/failure notice was being filed under a buffer keyed by our OWN
// nick - which no window ever opens for - instead of under "NickServ",
// making it look like the automatic identify produced no feedback at all,
// whether or not it actually worked.
val bufferName = if (isChannelName(target)) target else (nick ?: target)
addMessage(BufferKey(serverId, bufferName), IrcMessage(now, MessageKind.NOTICE, nick, text))
}
"NICK" -> {
val oldNick = nick ?: return
val newNick = line.params.getOrNull(0) ?: return
@@ -347,6 +471,7 @@ object ConnectionManager {
}
if (ownNicks[serverId] == oldNick) {
setOwnNick(serverId, newNick)
addMainLine(MessageKind.NICK, null, "You are now known as $newNick")
}
}
"QUIT" -> {
@@ -360,6 +485,27 @@ object ConnectionManager {
}
}
}
"INVITE" -> {
val channel = line.params.getOrNull(1) ?: return
addMainLine(MessageKind.SYSTEM, nick, "$nick invited you to $channel")
}
"ERROR" -> {
addMainLine(MessageKind.SYSTEM, null, "ERROR: ${line.params.joinToString(" ")}")
}
"PONG", "CAP" -> Unit
else -> {
when {
line.command in WHOIS_NUMERICS -> handleWhoisLine(serverId, line)
line.command in CHANNEL_ONLY_NUMERICS -> Unit
// Every other numeric (welcome, ISUPPORT, LUSERS, MOTD, errors, ...) is a
// server message; params[0] is just our own nick, so it's dropped.
line.command.all { it.isDigit() } ->
addMainLine(MessageKind.SYSTEM, line.prefix, line.params.drop(1).joinToString(" "))
// Anything else not tied to a channel (WALLOPS, KILL, ...) - wIRC main as-is.
else ->
addMainLine(MessageKind.SYSTEM, nick, "${line.command} ${line.params.joinToString(" ")}".trim())
}
}
}
}
}