Initial commit

This commit is contained in:
weishaupt
2026-09-01 12:32:15 -04:00
commit defdefdb28
33 changed files with 3540 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
package data
import java.awt.Desktop
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.
private const val RESERVED_RELOAD = "reload"
private val defaultAliasesContent = """
# wIRC alias commands. One alias per line: /name = type:target
# path:X opens X (a file or folder) in Explorer
# url:X opens X in the default browser
# run:X launches X as a process
# {arg} in the target is replaced with whatever text follows the command.
/c = path:C:\
/blog = url:https://bloggin.space
/url = url:{arg}
""".trimIndent() + "\n"
/**
* Global (not per-server/per-channel) alias command table, loaded once at app startup
* (see Main.kt) and shared by every ChannelWindow's command router.
*
* aliases.txt is bundled into the install directory by build.gradle.kts's
* appResourcesRootDir (see installerResources/aliases.txt), which puts it somewhere under
* Program Files - normally UAC-protected. On [init], this repository figures out whether
* that bundled copy can actually be hand-edited in place; if not, it falls back to a
* writable copy under %APPDATA%\wIRC instead and uses that from then on.
*/
object AliasRepository {
private var aliases: Map<String, String> = emptyMap()
lateinit var activeFile: File
private set
fun init() {
activeFile = resolveAliasFile()
aliases = loadAliases(activeFile)
println("[AliasRepository] Using aliases file: ${activeFile.absolutePath} (${aliases.size} aliases loaded)")
}
/** Re-reads aliases.txt from the currently active path. Returns the number of aliases loaded. */
fun reload(): Int {
aliases = loadAliases(activeFile)
return aliases.size
}
/**
* 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
* command handling". Returns false for unknown commands (including the reserved
* "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\"")
runAction(action, args)
return true
}
private fun runAction(action: String, args: String) {
val colonIdx = action.indexOf(':')
if (colonIdx < 0) return
val type = action.substring(0, colonIdx).trim()
val rawTarget = action.substring(colonIdx + 1).trim()
// URL targets need their {arg} substitution percent-encoded - java.net.URI's
// single-String constructor does strict RFC 2396 parsing and throws
// URISyntaxException on a raw space (e.g. "/src alexis road" against
// "url:...?search={arg}" produces "...?search=alexis road", which URI() rejects
// outright). That exception used to be swallowed by the catch below with only a
// println - invisible in a windowed app with no attached console, so a multi-word
// arg looked like the alias silently did nothing. path/run targets are real
// filesystem paths or process args, so they're left unencoded - a space there is
// 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)) }
// 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
// an exe's embedded "requireAdministrator" manifest - it just fails with
// CreateProcess error 740 (ERROR_ELEVATION_REQUIRED). "start" goes through
// ShellExecute instead, which shows the same UAC prompt a normal launch
// 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() }
else -> println("[AliasRepository] Unknown alias action type '$type' in \"$action\"")
}
} catch (e: Exception) {
println("[AliasRepository] Failed to run alias action \"$action\" (target=\"$target\"): ${e.message}")
}
}
// Percent-encodes only what would actually break URI parsing or misrepresent the
// argument (whitespace, quotes, non-ASCII, etc.), matching the standard
// application/x-www-form-urlencoded convention of encoding spaces as '+' (the ticket's
// expected "search=alexis+road" output). Structural/reserved URI characters
// (: / ? # & = etc.) are passed through unencoded so an alias like "/url = url:{arg}"
// still works when {arg} is itself a full URL rather than a query value.
private fun encodeArgForUrl(args: String): String {
val safe = "-_.~:/?#[]@!$&'()*+,;=%"
val sb = StringBuilder()
for (c in args) {
when {
c == ' ' -> sb.append('+')
c.isLetterOrDigit() && c.code < 128 -> sb.append(c)
c in safe -> sb.append(c)
else -> sb.append(URLEncoder.encode(c.toString(), "UTF-8"))
}
}
return sb.toString()
}
private fun loadAliases(file: File): Map<String, String> {
if (!file.exists()) return emptyMap()
val result = mutableMapOf<String, String>()
file.readLines().forEach { line ->
val trimmed = line.trim()
if (trimmed.isEmpty() || trimmed.startsWith("#")) return@forEach
val eqIdx = trimmed.indexOf('=')
if (eqIdx < 0) return@forEach
val name = trimmed.substring(0, eqIdx).trim().removePrefix("/").lowercase()
val action = trimmed.substring(eqIdx + 1).trim()
if (name.isEmpty() || action.isEmpty() || name == RESERVED_RELOAD) return@forEach
result[name] = action
}
return result
}
private fun resolveAliasFile(): File {
val appDataFile = File(File(appDataRoot(), "wIRC"), "aliases.txt")
val resourcesDirProp = System.getProperty("compose.application.resources.dir")
if (resourcesDirProp != null) {
val bundled = File(resourcesDirProp, "aliases.txt")
if (bundled.exists()) {
return if (isDirectoryWritable(bundled.parentFile)) {
// Program Files write protection isn't active (or this isn't an
// installed build) - edit the bundled copy in place, as intended.
bundled
} else {
println(
"[AliasRepository] ${bundled.absolutePath} is not writable " +
"(Program Files protection) - falling back to ${appDataFile.absolutePath}"
)
if (!appDataFile.exists()) {
appDataFile.parentFile?.mkdirs()
// Best-effort: carry over the bundled starter content. If even
// reading it fails (locked file, odd ACLs, etc.) fall back to the
// built-in default rather than leaving init() to crash the app.
try {
bundled.copyTo(appDataFile, overwrite = false)
} catch (e: Exception) {
println("[AliasRepository] Couldn't copy bundled aliases.txt (${e.message}) - writing default starter content instead")
appDataFile.writeText(defaultAliasesContent)
}
}
appDataFile
}
}
}
// Dev run (no installer resources dir set) or bundled file missing - seed a
// starter file directly under %APPDATA% so the feature still works from source.
if (!appDataFile.exists()) {
appDataFile.parentFile?.mkdirs()
appDataFile.writeText(defaultAliasesContent)
}
return appDataFile
}
private fun appDataRoot(): File =
System.getenv("APPDATA")?.let { File(it) }
?: File(System.getProperty("user.home"), "AppData/Roaming")
// File.canWrite() can lie (or simply be unavailable) on some Windows ACL setups, so
// this confirms writability the same way the file will actually be edited: a real
// write followed by cleanup.
private fun isDirectoryWritable(dir: File?): Boolean {
if (dir == null) return false
return try {
val probe = File(dir, ".wirc_write_test_${System.nanoTime()}.tmp")
probe.writeText("")
probe.delete()
true
} catch (e: Exception) {
false
}
}
}
@@ -0,0 +1,57 @@
package data
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/**
* Persists each buffer's scrollback to a JSON file under rootDir/history/, so messages
* survive an app restart. Capped at 500 messages per buffer, matching the in-memory cap
* ConnectionManager already applies.
*/
class MessageHistoryRepository(private val rootDir: File) {
private fun fileFor(key: BufferKey): File {
val dir = File(rootDir, "history")
if (!dir.exists()) dir.mkdirs()
val safeName = key.name.replace(Regex("[^a-zA-Z0-9]"), "_")
return File(dir, "${key.serverId}__${safeName}.json")
}
fun loadMessages(key: BufferKey): List<IrcMessage> {
val file = fileFor(key)
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 saveMessages(key: BufferKey, messages: List<IrcMessage>) {
val arr = JSONArray()
messages.takeLast(500).forEach { arr.put(toJson(it)) }
fileFor(key).writeText(arr.toString())
}
private fun toJson(m: IrcMessage): JSONObject = JSONObject().apply {
put("timestampMillis", m.timestampMillis)
put("kind", m.kind.name)
put("sender", m.sender ?: JSONObject.NULL)
put("rawText", m.rawText)
put("target", m.target ?: JSONObject.NULL)
}
private fun fromJson(o: JSONObject): IrcMessage = IrcMessage(
timestampMillis = o.optLong("timestampMillis", 0L),
kind = try {
MessageKind.valueOf(o.optString("kind", "SYSTEM"))
} catch (e: Exception) {
MessageKind.SYSTEM
},
sender = o.optString("sender", null).takeIf { it != "null" },
rawText = o.optString("rawText", ""),
target = o.optString("target", null).takeIf { it != "null" }
)
}
+61
View File
@@ -0,0 +1,61 @@
package data
/** A configured IRC server/network connection. */
data class ServerConfig(
val id: String, // stable key, e.g. "rizon"
val label: String, // display name, e.g. "Rizon"
val host: String,
val port: Int = 6667,
val useTls: Boolean = false,
val nick: String,
val autoJoinChannels: List<String> = emptyList(),
val saslUser: String? = null,
val saslPass: String? = null,
// Raw IRC commands sent right after registration (001), before auto-join channels.
// e.g. "PRIVMSG NickServ :IDENTIFY hunter2" or "NS IDENTIFY hunter2".
// This is the "on join perform" list — saved to disk and replayed every connect.
val onConnectCommands: List<String> = emptyList()
)
/** True for channel-style targets ('#', '&', '+', '!' prefixes per the IRC spec). */
fun isChannelName(name: String): Boolean =
name.isNotEmpty() && name[0] in "#&+!"
enum class MessageKind { CHAT, ACTION, JOIN, PART, QUIT, KICK, MODE, NOTICE, NICK, TOPIC, SYSTEM }
// Process-wide, so every IrcMessage ever constructed while the app is running gets a
// genuinely unique seq, regardless of which buffer/server it belongs to.
private val nextMessageSeq = java.util.concurrent.atomic.AtomicLong(0)
data class IrcMessage(
val timestampMillis: Long,
val kind: MessageKind,
val sender: String?, // nick, or null for system/server lines
val rawText: String, // still contains mIRC control codes; parsed at render time
val target: String? = null, // e.g. for KICK, the nick that got kicked
// Monotonically increasing, guaranteed-unique identity for this message - used as the
// LazyColumn item key in ChannelScreen (see there). The old key there was
// "${timestampMillis}_${sender}_${rawText.hashCode()}" - in a genuinely busy channel,
// two messages sharing the same millisecond, sender, and text (not rare for short
// repeated lines - "lol", a single emoji, etc.) would produce the exact same key, and
// Compose requires unique keys per item. Not persisted to disk - history reloaded on
// restart just gets fresh seq values in load order, which is fine since uniqueness only
// needs to hold within a single running session, not across restarts.
val seq: Long = nextMessageSeq.getAndIncrement()
)
/** Buffer key: server id + channel/query name (channel names include '#'). */
data class BufferKey(val serverId: String, val name: String)
data class ChannelUser(
val nick: String,
val isOp: Boolean = false,
val isVoice: Boolean = false
)
data class ChannelBuffer(
val key: BufferKey,
val messages: List<IrcMessage> = emptyList(),
val users: List<ChannelUser> = emptyList(),
val topic: String = ""
)
+103
View File
@@ -0,0 +1,103 @@
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) {
// 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) {
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")
)
}
}
@@ -0,0 +1,103 @@
package data
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
import java.io.File
import java.util.concurrent.ConcurrentHashMap
data class WindowDisplaySettings(
val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque
val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white
val fontSize: Float = 14f,
val showUserList: Boolean = true,
val mentionHighlightColorArgb: Int = 0x40FFA500.toInt(), // translucent orange
val nickColorsEnabled: Boolean = true,
val showImagePreviews: Boolean = false // opt-in: auto-loading images reveals your IP to the host
)
/**
* 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.
*/
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()
fun settingsFor(bufferKeyId: String): StateFlow<WindowDisplaySettings> = flowFor(bufferKeyId).asStateFlow()
fun setAlpha(bufferKeyId: String, alpha: Float) = update(bufferKeyId) { it.copy(backgroundAlpha = alpha) }
fun setTextColorMircIndex(bufferKeyId: String, index: Int) = update(bufferKeyId) { it.copy(textColorMircIndex = index) }
fun setFontSize(bufferKeyId: String, fontSize: Float) = update(bufferKeyId) { it.copy(fontSize = fontSize) }
fun setShowUserList(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showUserList = show) }
fun setMentionHighlightColor(bufferKeyId: String, colorArgb: Int) = update(bufferKeyId) { it.copy(mentionHighlightColorArgb = colorArgb) }
fun setNickColorsEnabled(bufferKeyId: String, enabled: Boolean) = update(bufferKeyId) { it.copy(nickColorsEnabled = enabled) }
fun setShowImagePreviews(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showImagePreviews = show) }
private fun flowFor(bufferKeyId: String): MutableStateFlow<WindowDisplaySettings> =
flows.getOrPut(bufferKeyId) { MutableStateFlow(stored[bufferKeyId] ?: WindowDisplaySettings()) }
private fun update(bufferKeyId: String, transform: (WindowDisplaySettings) -> WindowDisplaySettings) {
synchronized(lock) {
val updated = transform(stored[bufferKeyId] ?: WindowDisplaySettings())
stored[bufferKeyId] = updated
flowFor(bufferKeyId).value = updated
persist()
}
}
private fun loadAll(): MutableMap<String, WindowDisplaySettings> {
if (!file.exists()) return mutableMapOf()
return try {
val obj = JSONObject(file.readText())
val result = mutableMapOf<String, WindowDisplaySettings>()
obj.keys().forEach { key ->
val o = obj.getJSONObject(key)
result[key] = WindowDisplaySettings(
backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(),
textColorMircIndex = o.optInt("textColorMircIndex", 0),
fontSize = o.optDouble("fontSize", 14.0).toFloat(),
showUserList = o.optBoolean("showUserList", true),
mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(),
nickColorsEnabled = o.optBoolean("nickColorsEnabled", true),
showImagePreviews = o.optBoolean("showImagePreviews", false)
)
}
result
} catch (e: Exception) {
mutableMapOf()
}
}
private fun persist() {
val obj = JSONObject()
stored.forEach { (key, settings) ->
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)
})
}
file.parentFile?.mkdirs()
file.writeText(obj.toString())
}
}
fun BufferKey.storageId(): String = "${serverId}__${name}"