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:
co-authored by
Claude Opus 5.5
parent
242d7a7c42
commit
807903fe0f
@@ -5,13 +5,6 @@ import java.io.File
|
||||
import java.net.URI
|
||||
import java.net.URLEncoder
|
||||
|
||||
// TEMP DEBUG (see task: alias multi-word arguments investigation) - remove once confirmed
|
||||
// fixed. Traces the full alias pipeline: raw input -> matched alias -> substituted target
|
||||
// -> the actual Desktop API call, so a silent failure anywhere in that chain is visible.
|
||||
private fun aliasDebug(msg: String) {
|
||||
println("[alias-debug] $msg")
|
||||
}
|
||||
|
||||
// Name reserved for the app-level /reload command (ChannelWindow.kt handles it directly,
|
||||
// before alias lookup even runs) - kept here too so a line in aliases.txt can never define
|
||||
// an alias that would shadow it.
|
||||
@@ -64,15 +57,9 @@ object AliasRepository {
|
||||
* "reload" name, which this repository never loads as an alias).
|
||||
*/
|
||||
fun tryRunAlias(command: String, args: String): Boolean {
|
||||
aliasDebug("tryRunAlias: command=\"$command\" args=\"$args\"")
|
||||
val key = command.lowercase()
|
||||
if (key == RESERVED_RELOAD) return false
|
||||
val action = aliases[key]
|
||||
if (action == null) {
|
||||
aliasDebug("no alias matched for \"$key\"")
|
||||
return false
|
||||
}
|
||||
aliasDebug("matched \"$key\" -> \"$action\"")
|
||||
val action = aliases[key] ?: return false
|
||||
runAction(action, args)
|
||||
return true
|
||||
}
|
||||
@@ -94,13 +81,12 @@ object AliasRepository {
|
||||
// literal, not a URL delimiter.
|
||||
val substitutedArg = if (type == "url") encodeArgForUrl(args) else args
|
||||
val target = if (rawTarget.contains("{arg}")) rawTarget.replace("{arg}", substitutedArg) else rawTarget
|
||||
aliasDebug("type=\"$type\" rawTarget=\"$rawTarget\" substitutedArg=\"$substitutedArg\" -> target=\"$target\"")
|
||||
if (target.isBlank()) return
|
||||
|
||||
try {
|
||||
when (type) {
|
||||
"path" -> { aliasDebug("Desktop.open(File(\"$target\"))"); Desktop.getDesktop().open(File(target)) }
|
||||
"url" -> { aliasDebug("Desktop.browse(URI(\"$target\"))"); Desktop.getDesktop().browse(URI(target)) }
|
||||
"path" -> Desktop.getDesktop().open(File(target))
|
||||
"url" -> Desktop.getDesktop().browse(URI(target))
|
||||
// Routed through "cmd /c start" (same launch path as double-clicking in
|
||||
// Explorer or the Run dialog) rather than exec'd directly. A direct
|
||||
// ProcessBuilder(target).start() calls CreateProcess, which does NOT honor
|
||||
@@ -110,7 +96,7 @@ object AliasRepository {
|
||||
// would. This also preserves bare-name resolution via Windows' App Paths
|
||||
// registry / PATH - not guaranteed to work for every app, since it depends
|
||||
// on that app having registered itself with Windows.
|
||||
"run" -> { aliasDebug("ProcessBuilder start \"$target\""); ProcessBuilder("cmd", "/c", "start", "\"\"", target).start() }
|
||||
"run" -> ProcessBuilder("cmd", "/c", "start", "\"\"", target).start()
|
||||
else -> println("[AliasRepository] Unknown alias action type '$type' in \"$action\"")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -47,6 +47,35 @@ data class IrcMessage(
|
||||
/** Buffer key: server id + channel/query name (channel names include '#'). */
|
||||
data class BufferKey(val serverId: String, val name: String)
|
||||
|
||||
/**
|
||||
* wIRC main's own buffer: server messages (MOTD, numerics, errors, whois fallback) and
|
||||
* services/server notices from every connected server. Server ids are built from the host
|
||||
* with every non-alphanumeric character replaced by '_' (see ServerEditDialog), so one can
|
||||
* never contain '-' - this id can't collide with a real server. Also used as the
|
||||
* window_prefs.json key ("wirc-main__main") for the main window's saved position and size.
|
||||
*/
|
||||
val MAIN_BUFFER_KEY = BufferKey("wirc-main", "main")
|
||||
|
||||
// Nicks that are network services rather than people. Their PRIVMSGs and NOTICEs go to
|
||||
// wIRC main instead of opening a query window. Anything ending in "serv" is also treated
|
||||
// as a service, which covers the less common ones (MemoServ, HostServ, SaslServ, ...).
|
||||
private val KNOWN_SERVICE_NICKS = setOf("nickserv", "chanserv", "global", "alis", "q", "x")
|
||||
|
||||
fun isServiceNick(nick: String): Boolean {
|
||||
val lower = nick.lowercase()
|
||||
return lower in KNOWN_SERVICE_NICKS || lower.endsWith("serv")
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a message's source is a service or the server itself rather than a real user.
|
||||
* A user prefix always has the "nick!user@host" form; a missing prefix or one without '!'
|
||||
* is a server name (e.g. "irc.rizon.io").
|
||||
*/
|
||||
fun isServiceOrServerPrefix(prefix: String?): Boolean {
|
||||
if (prefix == null || !prefix.contains('!')) return true
|
||||
return isServiceNick(prefix.substringBefore('!'))
|
||||
}
|
||||
|
||||
data class ChannelUser(
|
||||
val nick: String,
|
||||
val isOp: Boolean = false,
|
||||
|
||||
@@ -42,16 +42,10 @@ class ServerRepository(rootDir: File) {
|
||||
}
|
||||
|
||||
fun upsert(server: ServerConfig) {
|
||||
// TEMP DEBUG (see task: on-connect identify command investigation) - confirms the
|
||||
// Edit Server dialog's Save actually persists onConnectCommands, by writing then
|
||||
// immediately re-reading the file back rather than trusting the in-memory value.
|
||||
println("[ServerRepository] upsert(${server.id}): saving onConnectCommands=${server.onConnectCommands}")
|
||||
val current = loadAll().toMutableList()
|
||||
val idx = current.indexOfFirst { it.id == server.id }
|
||||
if (idx >= 0) current[idx] = server else current.add(server)
|
||||
saveAll(current)
|
||||
val reloaded = loadAll().firstOrNull { it.id == server.id }
|
||||
println("[ServerRepository] upsert(${server.id}): reloaded from disk onConnectCommands=${reloaded?.onConnectCommands}")
|
||||
}
|
||||
|
||||
fun remove(serverId: String) {
|
||||
|
||||
@@ -5,6 +5,9 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.nio.file.AtomicMoveNotSupportedException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
data class WindowDisplaySettings(
|
||||
@@ -17,19 +20,41 @@ data class WindowDisplaySettings(
|
||||
val showImagePreviews: Boolean = false // opt-in: auto-loading images reveals your IP to the host
|
||||
)
|
||||
|
||||
/**
|
||||
* A window's last floating position and size, in dp (which Compose Desktop maps 1:1 to
|
||||
* AWT's logical screen coordinates). Every field is optional in window_prefs.json, so files
|
||||
* written before this existed still load - a window with no saved bounds just gets a
|
||||
* cascaded default (see ui/WindowPlacement.kt).
|
||||
*/
|
||||
data class WindowBounds(
|
||||
val x: Float?,
|
||||
val y: Float?,
|
||||
val width: Float?,
|
||||
val height: Float?
|
||||
)
|
||||
|
||||
/**
|
||||
* Desktop equivalent of the Android DataStore-backed repository. Settings for every
|
||||
* buffer are kept as one JSON file (rootDir/window_prefs.json), keyed by buffer storage
|
||||
* id, loaded once at startup and rewritten on every change. Each buffer's settings are
|
||||
* exposed as a StateFlow so every open window for that buffer updates live, same as
|
||||
* DataStore's Flow did on Android.
|
||||
*
|
||||
* Window bounds live in the same JSON object per key (windowX/windowY/windowWidth/
|
||||
* windowHeight) but are kept out of the display-settings StateFlow, so moving or resizing
|
||||
* a window doesn't recompose its content.
|
||||
*/
|
||||
class WindowPrefsRepository(rootDir: File) {
|
||||
|
||||
private val file = File(rootDir, "window_prefs.json")
|
||||
private val lock = Any()
|
||||
private val flows = ConcurrentHashMap<String, MutableStateFlow<WindowDisplaySettings>>()
|
||||
private var stored: MutableMap<String, WindowDisplaySettings> = loadAll()
|
||||
private val stored = mutableMapOf<String, WindowDisplaySettings>()
|
||||
private val storedBounds = mutableMapOf<String, WindowBounds>()
|
||||
|
||||
init {
|
||||
loadAll()
|
||||
}
|
||||
|
||||
fun settingsFor(bufferKeyId: String): StateFlow<WindowDisplaySettings> = flowFor(bufferKeyId).asStateFlow()
|
||||
|
||||
@@ -47,8 +72,18 @@ class WindowPrefsRepository(rootDir: File) {
|
||||
|
||||
fun setShowImagePreviews(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showImagePreviews = show) }
|
||||
|
||||
fun boundsFor(bufferKeyId: String): WindowBounds? = synchronized(lock) { storedBounds[bufferKeyId] }
|
||||
|
||||
fun setWindowBounds(bufferKeyId: String, bounds: WindowBounds) {
|
||||
synchronized(lock) {
|
||||
if (storedBounds[bufferKeyId] == bounds) return
|
||||
storedBounds[bufferKeyId] = bounds
|
||||
persist()
|
||||
}
|
||||
}
|
||||
|
||||
private fun flowFor(bufferKeyId: String): MutableStateFlow<WindowDisplaySettings> =
|
||||
flows.getOrPut(bufferKeyId) { MutableStateFlow(stored[bufferKeyId] ?: WindowDisplaySettings()) }
|
||||
flows.getOrPut(bufferKeyId) { MutableStateFlow(synchronized(lock) { stored[bufferKeyId] } ?: WindowDisplaySettings()) }
|
||||
|
||||
private fun update(bufferKeyId: String, transform: (WindowDisplaySettings) -> WindowDisplaySettings) {
|
||||
synchronized(lock) {
|
||||
@@ -59,14 +94,16 @@ class WindowPrefsRepository(rootDir: File) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAll(): MutableMap<String, WindowDisplaySettings> {
|
||||
if (!file.exists()) return mutableMapOf()
|
||||
return try {
|
||||
private fun JSONObject.optFloatOrNull(key: String): Float? =
|
||||
if (has(key) && !isNull(key)) optDouble(key).toFloat().takeIf { !it.isNaN() } else null
|
||||
|
||||
private fun loadAll() {
|
||||
if (!file.exists()) return
|
||||
try {
|
||||
val obj = JSONObject(file.readText())
|
||||
val result = mutableMapOf<String, WindowDisplaySettings>()
|
||||
obj.keys().forEach { key ->
|
||||
val o = obj.getJSONObject(key)
|
||||
result[key] = WindowDisplaySettings(
|
||||
stored[key] = WindowDisplaySettings(
|
||||
backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(),
|
||||
textColorMircIndex = o.optInt("textColorMircIndex", 0),
|
||||
fontSize = o.optDouble("fontSize", 14.0).toFloat(),
|
||||
@@ -75,28 +112,53 @@ class WindowPrefsRepository(rootDir: File) {
|
||||
nickColorsEnabled = o.optBoolean("nickColorsEnabled", true),
|
||||
showImagePreviews = o.optBoolean("showImagePreviews", false)
|
||||
)
|
||||
val bounds = WindowBounds(
|
||||
x = o.optFloatOrNull("windowX"),
|
||||
y = o.optFloatOrNull("windowY"),
|
||||
width = o.optFloatOrNull("windowWidth"),
|
||||
height = o.optFloatOrNull("windowHeight")
|
||||
)
|
||||
if (bounds != WindowBounds(null, null, null, null)) storedBounds[key] = bounds
|
||||
}
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
mutableMapOf()
|
||||
stored.clear()
|
||||
storedBounds.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Written to a temp file and moved into place, same as MessageHistoryRepository - this
|
||||
// file is now rewritten every time a window is moved or resized, so a crash mid-write
|
||||
// must not be able to leave a truncated file (which loadAll would read as "no settings
|
||||
// at all" and the next save would make permanent).
|
||||
private fun persist() {
|
||||
val obj = JSONObject()
|
||||
stored.forEach { (key, settings) ->
|
||||
(stored.keys + storedBounds.keys).forEach { key ->
|
||||
obj.put(key, JSONObject().apply {
|
||||
put("backgroundAlpha", settings.backgroundAlpha)
|
||||
put("textColorMircIndex", settings.textColorMircIndex)
|
||||
put("fontSize", settings.fontSize)
|
||||
put("showUserList", settings.showUserList)
|
||||
put("mentionHighlightColorArgb", settings.mentionHighlightColorArgb)
|
||||
put("nickColorsEnabled", settings.nickColorsEnabled)
|
||||
put("showImagePreviews", settings.showImagePreviews)
|
||||
stored[key]?.let { settings ->
|
||||
put("backgroundAlpha", settings.backgroundAlpha)
|
||||
put("textColorMircIndex", settings.textColorMircIndex)
|
||||
put("fontSize", settings.fontSize)
|
||||
put("showUserList", settings.showUserList)
|
||||
put("mentionHighlightColorArgb", settings.mentionHighlightColorArgb)
|
||||
put("nickColorsEnabled", settings.nickColorsEnabled)
|
||||
put("showImagePreviews", settings.showImagePreviews)
|
||||
}
|
||||
storedBounds[key]?.let { bounds ->
|
||||
bounds.x?.let { put("windowX", it) }
|
||||
bounds.y?.let { put("windowY", it) }
|
||||
bounds.width?.let { put("windowWidth", it) }
|
||||
bounds.height?.let { put("windowHeight", it) }
|
||||
}
|
||||
})
|
||||
}
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(obj.toString())
|
||||
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||
tmp.writeText(obj.toString())
|
||||
try {
|
||||
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
|
||||
} catch (e: AtomicMoveNotSupportedException) {
|
||||
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user