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>
98 lines
3.9 KiB
Kotlin
98 lines
3.9 KiB
Kotlin
package data
|
|
|
|
import org.json.JSONArray
|
|
import org.json.JSONObject
|
|
import java.io.File
|
|
|
|
/**
|
|
* Desktop equivalent of the Android EncryptedSharedPreferences-backed repository.
|
|
* Persists the server list — including each server's "on join perform" commands
|
|
* (NickServ/ChanServ identify, etc.) — as a single JSON file (rootDir/servers.json).
|
|
* Loaded once at app start and replayed automatically on every connect, so you only
|
|
* ever type your identify commands in once.
|
|
*
|
|
* NOTE ON SECRECY: the Android version encrypts this at rest via EncryptedSharedPreferences
|
|
* backed by an Android Keystore master key, so identify passwords never sit in plaintext.
|
|
* There is no OS-backed secret store used here — desktop has no direct equivalent that
|
|
* this project depends on. This file is plaintext JSON; the best this does is restrict
|
|
* its OS file permissions to the current user (best-effort via File.setReadable/setWritable,
|
|
* since Windows has no POSIX permission bits to set directly). That is meaningfully weaker
|
|
* than the Android Keystore-backed encryption - treat it accordingly.
|
|
*/
|
|
class ServerRepository(rootDir: File) {
|
|
|
|
private val file = File(rootDir, "servers.json")
|
|
|
|
fun loadAll(): List<ServerConfig> {
|
|
if (!file.exists()) return emptyList()
|
|
return try {
|
|
val arr = JSONArray(file.readText())
|
|
(0 until arr.length()).map { i -> fromJson(arr.getJSONObject(i)) }
|
|
} catch (e: Exception) {
|
|
emptyList()
|
|
}
|
|
}
|
|
|
|
fun saveAll(servers: List<ServerConfig>) {
|
|
val arr = JSONArray()
|
|
servers.forEach { arr.put(toJson(it)) }
|
|
file.parentFile?.mkdirs()
|
|
file.writeText(arr.toString())
|
|
restrictToOwner(file)
|
|
}
|
|
|
|
fun upsert(server: ServerConfig) {
|
|
val current = loadAll().toMutableList()
|
|
val idx = current.indexOfFirst { it.id == server.id }
|
|
if (idx >= 0) current[idx] = server else current.add(server)
|
|
saveAll(current)
|
|
}
|
|
|
|
fun remove(serverId: String) {
|
|
saveAll(loadAll().filterNot { it.id == serverId })
|
|
}
|
|
|
|
// Best-effort "only the current user can read this" - Windows has no POSIX mode
|
|
// bits, so File.setReadable/setWritable(_, ownerOnly = true) is the closest plain
|
|
// java.io.File gets; it maps to denying the "Everyone" ACL entry rather than a
|
|
// real per-user ACL, which is weaker than the Android Keystore-backed version.
|
|
private fun restrictToOwner(file: File) {
|
|
file.setReadable(false, false)
|
|
file.setReadable(true, true)
|
|
file.setWritable(false, false)
|
|
file.setWritable(true, true)
|
|
}
|
|
|
|
private fun toJson(s: ServerConfig): JSONObject = JSONObject().apply {
|
|
put("id", s.id)
|
|
put("label", s.label)
|
|
put("host", s.host)
|
|
put("port", s.port)
|
|
put("useTls", s.useTls)
|
|
put("nick", s.nick)
|
|
put("autoJoinChannels", JSONArray(s.autoJoinChannels))
|
|
put("saslUser", s.saslUser ?: JSONObject.NULL)
|
|
put("saslPass", s.saslPass ?: JSONObject.NULL)
|
|
put("onConnectCommands", JSONArray(s.onConnectCommands))
|
|
}
|
|
|
|
private fun fromJson(o: JSONObject): ServerConfig {
|
|
fun strArray(key: String): List<String> {
|
|
val arr = o.optJSONArray(key) ?: return emptyList()
|
|
return (0 until arr.length()).map { arr.getString(it) }
|
|
}
|
|
return ServerConfig(
|
|
id = o.getString("id"),
|
|
label = o.getString("label"),
|
|
host = o.getString("host"),
|
|
port = o.optInt("port", 6667),
|
|
useTls = o.optBoolean("useTls", false),
|
|
nick = o.getString("nick"),
|
|
autoJoinChannels = strArray("autoJoinChannels"),
|
|
saslUser = o.optString("saslUser", null).takeIf { it != "null" },
|
|
saslPass = o.optString("saslPass", null).takeIf { it != "null" },
|
|
onConnectCommands = strArray("onConnectCommands")
|
|
)
|
|
}
|
|
}
|