Initial commit
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import data.AliasRepository
|
||||
import data.BufferKey
|
||||
import data.ServerRepository
|
||||
import data.WindowPrefsRepository
|
||||
import irc.ConnectionManager
|
||||
import ui.ChannelWindow
|
||||
import ui.MainScreen
|
||||
import java.io.File
|
||||
|
||||
// Material 3's darkColorScheme() defaults to a purple accent; this keeps everything
|
||||
// (OutlinedTextField borders, Button/TextButton labels, etc.) neutral black-and-white
|
||||
// to match the rest of the app's deliberately plain look, same as the Android version.
|
||||
private val neutralDarkScheme = darkColorScheme(
|
||||
primary = Color.White,
|
||||
onPrimary = Color.Black,
|
||||
secondary = Color.White,
|
||||
onSecondary = Color.Black,
|
||||
background = Color.Black,
|
||||
onBackground = Color.White,
|
||||
surface = Color.Black,
|
||||
onSurface = Color.White,
|
||||
outline = Color.White
|
||||
)
|
||||
|
||||
// All persistence lives under the user's home directory - the desktop analogue of
|
||||
// Android's app-private filesDir, since a desktop app has no sandboxed storage of its own.
|
||||
private val appRoot = File(System.getProperty("user.home"), ".wirc")
|
||||
|
||||
fun main() = application {
|
||||
if (!appRoot.exists()) appRoot.mkdirs()
|
||||
ConnectionManager.init(appRoot)
|
||||
// Loaded once here (not per-window) so the alias map is truly global - every
|
||||
// ChannelWindow's command router reads from this same AliasRepository singleton.
|
||||
AliasRepository.init()
|
||||
|
||||
val repo = remember { ServerRepository(appRoot) }
|
||||
val prefsRepo = remember { WindowPrefsRepository(appRoot) }
|
||||
|
||||
var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
|
||||
|
||||
// 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.
|
||||
val shutdownApp: () -> Unit = {
|
||||
ConnectionManager.disconnectAll()
|
||||
exitApplication()
|
||||
}
|
||||
|
||||
// Desktop OSes don't kill backgrounded network connections for battery reasons the
|
||||
// way Android does, so there's no foreground-service equivalent needed here -
|
||||
// ConnectionManager's sockets just live for as long as this process runs, and this
|
||||
// listener (which the Android foreground service used to own) can live directly at
|
||||
// the application level instead.
|
||||
LaunchedEffect(Unit) {
|
||||
ConnectionManager.newQueryBuffers.collect { key ->
|
||||
openBuffers = openBuffers + key
|
||||
}
|
||||
}
|
||||
|
||||
// There's no persistent tray/notification surface on this build, so closing the main
|
||||
// server-list window quits the whole app (and every channel window with it) - the
|
||||
// same "closing the primary window ends the session" convention most desktop chat
|
||||
// apps without a tray icon follow.
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "wIRC - Main",
|
||||
icon = painterResource("icon.png"),
|
||||
state = rememberWindowState()
|
||||
) {
|
||||
MaterialTheme(colorScheme = neutralDarkScheme) {
|
||||
Surface {
|
||||
MainScreen(
|
||||
repo = repo,
|
||||
colorScheme = neutralDarkScheme,
|
||||
onOpenChannel = { serverId, channelName ->
|
||||
openBuffers = openBuffers + BufferKey(serverId, channelName)
|
||||
},
|
||||
onExitApp = shutdownApp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openBuffers.forEach { bufferKey ->
|
||||
key(bufferKey) {
|
||||
ChannelWindow(
|
||||
bufferKey = bufferKey,
|
||||
prefsRepo = prefsRepo,
|
||||
colorScheme = neutralDarkScheme,
|
||||
onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey },
|
||||
onCloseSelf = { openBuffers = openBuffers - bufferKey },
|
||||
onQuitApp = {
|
||||
exitApplication()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
)
|
||||
}
|
||||
@@ -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 = ""
|
||||
)
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,343 @@
|
||||
package irc
|
||||
|
||||
import data.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
// Per-buffer message history cap. Named so it's a one-line change to temporarily lower
|
||||
// for testing what happens once a busy channel's buffer actually hits the cap (auto-scroll
|
||||
// keying on the wrong thing is invisible in a quiet test channel that never reaches this).
|
||||
private const val MESSAGE_HISTORY_CAP = 500
|
||||
|
||||
// How long to wait after a message before actually writing that buffer's history to disk,
|
||||
// coalescing any further messages that arrive in the meantime into the same write. See
|
||||
// scheduleSave() - previously every single message triggered its own full save, which is
|
||||
// a lot of disk I/O to keep up with in a busy channel and was the likely direct cause of
|
||||
// reported slowness under real message volume.
|
||||
private const val SAVE_DEBOUNCE_MS = 2500L
|
||||
|
||||
/**
|
||||
* App-wide singleton. Owns every IrcConnection and every ChannelBuffer, so that all
|
||||
* windows read from the same source of truth via StateFlow.
|
||||
*/
|
||||
object ConnectionManager {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val connections = ConcurrentHashMap<String, IrcConnection>()
|
||||
private val ownNicks = ConcurrentHashMap<String, String>() // serverId -> our current nick
|
||||
private var historyRepo: MessageHistoryRepository? = null
|
||||
|
||||
private val _buffers = MutableStateFlow<Map<BufferKey, ChannelBuffer>>(emptyMap())
|
||||
val buffers: StateFlow<Map<BufferKey, ChannelBuffer>> = _buffers
|
||||
|
||||
private val _statuses = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val statuses: StateFlow<Map<String, String>> = _statuses
|
||||
|
||||
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
|
||||
|
||||
fun init(rootDir: File) {
|
||||
historyRepo = MessageHistoryRepository(rootDir)
|
||||
}
|
||||
|
||||
private fun setOwnNick(serverId: String, nick: String) {
|
||||
ownNicks[serverId] = nick
|
||||
_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
|
||||
|
||||
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
|
||||
// rest of the process once a server had been connected once - editing that
|
||||
// server's config afterward (identify commands, autojoin, etc.) and re-saving
|
||||
// had zero effect until a full app restart, because the running IrcConnection's
|
||||
// `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)
|
||||
val conn = IrcConnection(
|
||||
config = config,
|
||||
scope = scope,
|
||||
onLine = { line -> handleLine(config.id, line) },
|
||||
onStatus = { msg -> _statuses.update { it + (config.id to msg) } }
|
||||
)
|
||||
connections[config.id] = conn
|
||||
conn.connect()
|
||||
}
|
||||
|
||||
fun disconnectAll() {
|
||||
connections.values.forEach { it.disconnect() }
|
||||
connections.clear()
|
||||
flushAllSaves()
|
||||
}
|
||||
|
||||
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)
|
||||
val myNick = ownNicks[serverId] ?: return
|
||||
addMessage(
|
||||
BufferKey(serverId, target),
|
||||
IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, 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")
|
||||
val myNick = ownNicks[serverId] ?: return
|
||||
addMessage(
|
||||
BufferKey(serverId, target),
|
||||
IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action)
|
||||
)
|
||||
}
|
||||
|
||||
// Raw passthrough for slash commands that don't have dedicated handling.
|
||||
fun sendRaw(serverId: String, raw: String) {
|
||||
connectionFor(serverId)?.send(raw)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
addMessage(BufferKey(serverId, target), IrcMessage(System.currentTimeMillis(), MessageKind.SYSTEM, null, text))
|
||||
}
|
||||
|
||||
// One mutex per buffer key, guarding the "is this buffer new -> seed its history from
|
||||
// disk if so -> append the new message" sequence for that key end to end. Previously
|
||||
// that sequence was: a synchronous isNewBuffer read, then a separate async coroutine
|
||||
// that did the disk seed-load and the in-memory update - two steps with a real window
|
||||
// between them. In a busy channel, several messages for a brand-new buffer (e.g. right
|
||||
// after JOIN, when NAMES/TOPIC/a flood of chat lines all land within milliseconds) could
|
||||
// each see isNewBuffer=true before any of them had finished writing, each independently
|
||||
// re-reading the same stale on-disk snapshot and racing to write - concretely wasteful
|
||||
// (redundant disk reads) and, since MessageHistoryRepository.saveMessages() does a plain
|
||||
// unsynchronized file.writeText() with no ordering guarantee between concurrent callers,
|
||||
// capable of persisting a stale/incomplete snapshot last even when the in-memory state
|
||||
// was already fully correct. A per-key Mutex makes the whole check-seed-append sequence
|
||||
// for a given key strictly one-at-a-time, so only the genuinely first caller ever seeds
|
||||
// from disk and no two writers for the same key can be in that window simultaneously.
|
||||
private val bufferMutexes = ConcurrentHashMap<BufferKey, Mutex>()
|
||||
private fun mutexFor(key: BufferKey): Mutex = bufferMutexes.computeIfAbsent(key) { Mutex() }
|
||||
|
||||
// Pending debounced saves, keyed by buffer - see scheduleSave().
|
||||
private val pendingSaveJobs = ConcurrentHashMap<BufferKey, Job>()
|
||||
|
||||
private fun addMessage(key: BufferKey, msg: IrcMessage) {
|
||||
// Do the file I/O (seeding a brand-new buffer from disk, then persisting the
|
||||
// updated list) off the caller's thread - addMessage gets called both from the
|
||||
// IO-dispatched read loop and directly from UI-thread local-echo sends.
|
||||
scope.launch(Dispatchers.IO) {
|
||||
mutexFor(key).withLock {
|
||||
val isNewBuffer = !_buffers.value.containsKey(key)
|
||||
val seeded = if (isNewBuffer) (historyRepo?.loadMessages(key) ?: emptyList()) else emptyList()
|
||||
_buffers.update { map ->
|
||||
val existing = map[key] ?: ChannelBuffer(key, messages = seeded)
|
||||
val trimmed = (existing.messages + msg).takeLast(MESSAGE_HISTORY_CAP)
|
||||
map + (key to existing.copy(messages = trimmed))
|
||||
}
|
||||
if (isNewBuffer && !isChannelName(key.name)) {
|
||||
_newQueryBuffers.emit(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
scheduleSave(key)
|
||||
}
|
||||
|
||||
// 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
|
||||
// _buffers.value fresh at write time (not at schedule time), so whichever message
|
||||
// triggers the eventual write, it always persists the latest state, not a stale
|
||||
// snapshot from whenever it happened to be scheduled. computeIfAbsent is atomic, so
|
||||
// concurrent callers for the same key can only ever schedule one pending job.
|
||||
private fun scheduleSave(key: BufferKey) {
|
||||
pendingSaveJobs.computeIfAbsent(key) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(SAVE_DEBOUNCE_MS)
|
||||
pendingSaveJobs.remove(key)
|
||||
val toSave = _buffers.value[key]?.messages ?: emptyList()
|
||||
historyRepo?.saveMessages(key, toSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bypasses the debounce and writes every buffer's current state immediately - called on
|
||||
// shutdown so the last few seconds of a debounce window are never silently lost.
|
||||
private fun flushAllSaves() {
|
||||
pendingSaveJobs.values.forEach { it.cancel() }
|
||||
pendingSaveJobs.clear()
|
||||
_buffers.value.forEach { (key, buffer) -> historyRepo?.saveMessages(key, buffer.messages) }
|
||||
}
|
||||
|
||||
private fun updateUsers(key: BufferKey, transform: (List<ChannelUser>) -> List<ChannelUser>) {
|
||||
_buffers.update { map ->
|
||||
val existing = map[key] ?: ChannelBuffer(key)
|
||||
map + (key to existing.copy(users = transform(existing.users)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleLine(serverId: String, line: ParsedLine) {
|
||||
val now = System.currentTimeMillis()
|
||||
val nick = nickFromPrefix(line.prefix) ?: line.prefix
|
||||
|
||||
when (line.command) {
|
||||
"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)
|
||||
)
|
||||
}
|
||||
"JOIN" -> {
|
||||
val channel = line.params.getOrNull(0) ?: return
|
||||
addMessage(BufferKey(serverId, channel), IrcMessage(now, MessageKind.JOIN, nick, "$nick joined $channel"))
|
||||
if (nick != null) updateUsers(BufferKey(serverId, channel)) { it + ChannelUser(nick) }
|
||||
}
|
||||
"PART" -> {
|
||||
val channel = line.params.getOrNull(0) ?: return
|
||||
addMessage(BufferKey(serverId, channel), IrcMessage(now, MessageKind.PART, nick, "$nick left $channel"))
|
||||
if (nick != null) updateUsers(BufferKey(serverId, channel)) { users -> users.filterNot { it.nick == nick } }
|
||||
}
|
||||
"KICK" -> {
|
||||
val channel = line.params.getOrNull(0) ?: return
|
||||
val kicked = line.params.getOrNull(1) ?: return
|
||||
val reason = line.params.getOrNull(2) ?: ""
|
||||
addMessage(
|
||||
BufferKey(serverId, channel),
|
||||
IrcMessage(now, MessageKind.KICK, nick, "$kicked was kicked by $nick ($reason)", target = kicked)
|
||||
)
|
||||
updateUsers(BufferKey(serverId, channel)) { users -> users.filterNot { it.nick == kicked } }
|
||||
}
|
||||
"MODE" -> {
|
||||
val channel = line.params.getOrNull(0) ?: return
|
||||
if (!channel.startsWith("#")) return
|
||||
val modeStr = line.params.getOrNull(1) ?: ""
|
||||
val modeTarget = line.params.getOrNull(2)
|
||||
addMessage(
|
||||
BufferKey(serverId, channel),
|
||||
IrcMessage(now, MessageKind.MODE, nick, "$nick sets mode $modeStr ${modeTarget ?: ""}".trim())
|
||||
)
|
||||
if (modeTarget != null && (modeStr == "+o" || modeStr == "-o" || modeStr == "+v" || modeStr == "-v")) {
|
||||
updateUsers(BufferKey(serverId, channel)) { users ->
|
||||
users.map { u ->
|
||||
if (u.nick != modeTarget) u
|
||||
else when (modeStr) {
|
||||
"+o" -> u.copy(isOp = true)
|
||||
"-o" -> u.copy(isOp = false)
|
||||
"+v" -> u.copy(isVoice = true)
|
||||
"-v" -> u.copy(isVoice = false)
|
||||
else -> u
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"TOPIC" -> {
|
||||
val channel = line.params.getOrNull(0) ?: return
|
||||
val topic = line.params.getOrNull(1) ?: ""
|
||||
_buffers.update { map ->
|
||||
val key = BufferKey(serverId, channel)
|
||||
val existing = map[key] ?: ChannelBuffer(key)
|
||||
map + (key to existing.copy(topic = topic))
|
||||
}
|
||||
addMessage(BufferKey(serverId, channel), IrcMessage(now, MessageKind.TOPIC, nick, "$nick changed topic: $topic"))
|
||||
}
|
||||
"353" -> { // NAMES reply
|
||||
val channel = line.params.getOrNull(2) ?: return
|
||||
val names = line.params.getOrNull(3)?.split(" ")?.filter { it.isNotBlank() } ?: return
|
||||
val parsedUsers = names.map { raw ->
|
||||
when {
|
||||
raw.startsWith("@") -> ChannelUser(raw.removePrefix("@"), isOp = true)
|
||||
raw.startsWith("+") -> ChannelUser(raw.removePrefix("+"), isVoice = true)
|
||||
else -> ChannelUser(raw)
|
||||
}
|
||||
}
|
||||
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
|
||||
val affectedKeys = _buffers.value.filterKeys { it.serverId == serverId }
|
||||
.filterValues { buf -> buf.users.any { it.nick == oldNick } }
|
||||
.keys
|
||||
_buffers.update { map ->
|
||||
map.mapValues { (key, buf) ->
|
||||
if (key.serverId == serverId && buf.users.any { it.nick == oldNick }) {
|
||||
buf.copy(users = buf.users.map { u -> if (u.nick == oldNick) u.copy(nick = newNick) else u })
|
||||
} else buf
|
||||
}
|
||||
}
|
||||
affectedKeys.forEach { key ->
|
||||
addMessage(key, IrcMessage(now, MessageKind.SYSTEM, null, "$oldNick is now known as $newNick"))
|
||||
}
|
||||
if (ownNicks[serverId] == oldNick) {
|
||||
setOwnNick(serverId, newNick)
|
||||
}
|
||||
}
|
||||
"QUIT" -> {
|
||||
val reason = line.params.getOrNull(0) ?: ""
|
||||
// Remove nick from every channel buffer on this server it appears in.
|
||||
_buffers.update { map ->
|
||||
map.mapValues { (key, buf) ->
|
||||
if (key.serverId == serverId && nick != null && buf.users.any { it.nick == nick }) {
|
||||
buf.copy(users = buf.users.filterNot { it.nick == nick })
|
||||
} else buf
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package irc
|
||||
|
||||
import data.ServerConfig
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.io.PrintWriter
|
||||
import java.net.Socket
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
|
||||
// TEMP DEBUG (see task: closed-channel-doesn't-receive-messages investigation) - remove
|
||||
// once the overnight rejoin issue is confirmed fixed. Prefixed so it's easy to grep out.
|
||||
private fun ircDebug(serverId: String, msg: String) {
|
||||
println("[irc-debug][$serverId] $msg")
|
||||
}
|
||||
|
||||
// Extra buffer after end-of-MOTD (376/422) before actually sending on-connect commands -
|
||||
// see runPostRegistration(). Some networks' services genuinely aren't ready the same
|
||||
// instant MOTD ends; this is cheap insurance on top of that already-real trigger.
|
||||
private const val POST_MOTD_IDENTIFY_DELAY_MS = 1500L
|
||||
|
||||
// on-connect commands are meant to be raw IRC protocol lines (e.g.
|
||||
// "PRIVMSG NickServ :IDENTIFY password"), but the app's own chat window teaches
|
||||
// "/msg NickServ IDENTIFY password" as the correct thing to type (it IS correct there -
|
||||
// ChannelWindow.kt's onSend translates that shorthand before sending) - an easy, natural
|
||||
// mistake to reuse that exact phrasing in the on-connect-commands field, which has no such
|
||||
// translation and just writes the string to the socket byte-for-byte. A real network
|
||||
// confirmed this: the raw line got rejected outright with "421 Unknown command" before
|
||||
// NickServ ever saw it, since IRC servers have no "/msg" command. This makes on-connect
|
||||
// commands tolerant of that shorthand too, so either form works.
|
||||
private fun translateOnConnectCommand(cmd: String): String {
|
||||
val trimmed = cmd.trim()
|
||||
if (!trimmed.startsWith("/msg ", ignoreCase = true)) return cmd
|
||||
val rest = trimmed.substring(5).trim()
|
||||
val spaceIdx = rest.indexOf(' ')
|
||||
if (spaceIdx <= 0) return cmd
|
||||
val target = rest.substring(0, spaceIdx)
|
||||
val message = rest.substring(spaceIdx + 1).trim()
|
||||
if (message.isEmpty()) return cmd
|
||||
return "PRIVMSG $target :$message"
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns one socket to one IRC server. Emits raw parsed lines via onLine.
|
||||
* Handles registration (NICK/USER), and basic auto-reconnect with backoff.
|
||||
*/
|
||||
class IrcConnection(
|
||||
val config: ServerConfig,
|
||||
private val scope: CoroutineScope,
|
||||
private val onLine: (ParsedLine) -> Unit,
|
||||
private val onStatus: (String) -> Unit
|
||||
) {
|
||||
// @Volatile: read from the outbox-consumer coroutine, written from the read-loop
|
||||
// coroutine - without it there's no cross-thread visibility guarantee, so the outbox
|
||||
// consumer could keep observing a stale non-null `writer` from a socket that already
|
||||
// closed (or a stale null after a new one connected), silently mis-routing sends.
|
||||
@Volatile private var socket: Socket? = null
|
||||
@Volatile private var writer: PrintWriter? = null
|
||||
private var readJob: Job? = null
|
||||
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>()
|
||||
|
||||
// Guards runPostRegistration() against running twice for the same connection cycle
|
||||
// (376 and 422 are mutually exclusive per RFC, but this is cheap insurance) - reset
|
||||
// to false at the top of every connect attempt so a reconnect re-arms it.
|
||||
@Volatile private var registeredHandled = false
|
||||
|
||||
// Outgoing lines go through a single serial consumer instead of one
|
||||
// scope.launch(Dispatchers.IO) per send() call. That old approach had two problems:
|
||||
// (1) no ordering guarantee between independently-scheduled coroutines on a thread
|
||||
// pool dispatcher, and (2) if the socket dropped between a send() call being made and
|
||||
// its coroutine actually running, `writer` would already be null and the line was
|
||||
// silently discarded with no trace - which made a dropped JOIN indistinguishable from
|
||||
// one that was never requested. This queue preserves order and gives a single place
|
||||
// to log what actually made it onto the wire vs. what got dropped.
|
||||
private val outbox = Channel<String>(Channel.UNLIMITED)
|
||||
|
||||
init {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
for (raw in outbox) {
|
||||
val w = writer
|
||||
if (w == null) {
|
||||
// Kept (unlike the old per-line CHECKPOINT4 write-confirmation logging,
|
||||
// removed - it fired on every single outbound line and was real
|
||||
// console-I/O overhead under busy-channel volume, per println being
|
||||
// synchronized/blocking) - this specific case should be rare and is
|
||||
// worth knowing about if it ever happens.
|
||||
ircDebug(config.id, "DROPPED (writer is null - not connected): \"$raw\"")
|
||||
} else {
|
||||
w.println(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
wantConnected = true
|
||||
readJob?.cancel()
|
||||
readJob = scope.launch(Dispatchers.IO) {
|
||||
while (wantConnected && isActive) {
|
||||
var fallbackJob: Job? = null
|
||||
try {
|
||||
onStatus("Connecting to ${config.host}:${config.port}...")
|
||||
ircDebug(config.id, "connecting to ${config.host}:${config.port} (tracked channels: $joinedChannels)")
|
||||
val sock: Socket = if (config.useTls) {
|
||||
val sslSocket = SSLSocketFactory.getDefault().createSocket(config.host, config.port) as SSLSocket
|
||||
val sslParams = sslSocket.sslParameters
|
||||
sslParams.endpointIdentificationAlgorithm = "HTTPS"
|
||||
sslSocket.sslParameters = sslParams
|
||||
sslSocket.startHandshake()
|
||||
sslSocket
|
||||
} else {
|
||||
Socket(config.host, config.port)
|
||||
}
|
||||
socket = sock
|
||||
val out = PrintWriter(OutputStreamWriter(sock.getOutputStream(), Charsets.UTF_8), true)
|
||||
writer = out
|
||||
|
||||
// Used to send "CAP REQ :sasl" here whenever config.saslUser was set, but
|
||||
// there was never any AUTHENTICATE/CAP END follow-through - SASL isn't
|
||||
// actually implemented (there's no UI to even set saslUser/saslPass). Per
|
||||
// the IRCv3 CAP spec, a compliant server MUST hold off sending 001 until
|
||||
// it sees CAP END, so starting a negotiation the client can never finish
|
||||
// risked silently stalling registration forever on strict servers -
|
||||
// meaning 001/376/422 (and therefore the on-connect identify command)
|
||||
// would never fire at all. Removed until SASL is actually implemented
|
||||
// end-to-end.
|
||||
out.println("NICK ${config.nick}")
|
||||
out.println("USER ${config.nick} 0 * :${config.nick}")
|
||||
|
||||
backoffMillis = 2000L
|
||||
onStatus("Connected, registering...")
|
||||
registeredHandled = false
|
||||
// On-connect commands (identify, etc.) used to fire straight off 001 -
|
||||
// technically "registered" but too early on plenty of real networks:
|
||||
// NickServ/services in general aren't guaranteed ready for PRIVMSGs the
|
||||
// instant 001 lands, which is exactly why real clients (mIRC, HexChat,
|
||||
// irssi) fire "perform"/on-connect scripts off end-of-MOTD (376/422)
|
||||
// instead - see runPostRegistration(). This matches that convention, with
|
||||
// an 8s fallback in case a server never sends either (rare, but some
|
||||
// minimal/bouncer-style endpoints omit MOTD numerics entirely).
|
||||
fallbackJob = scope.launch {
|
||||
delay(8000)
|
||||
if (!registeredHandled) {
|
||||
ircDebug(config.id, "no 376/422 within 8s of connecting - running perform+rejoin anyway (fallback)")
|
||||
runPostRegistration()
|
||||
}
|
||||
}
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(sock.getInputStream(), Charsets.UTF_8))
|
||||
while (isActive) {
|
||||
val line = reader.readLine() ?: break
|
||||
handleRawLine(line)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onStatus("Connection error: ${e.message}")
|
||||
ircDebug(config.id, "connection dropped: ${e.message} - tracked channels at drop time: $joinedChannels")
|
||||
} finally {
|
||||
fallbackJob?.cancel()
|
||||
closeSocketQuietly()
|
||||
}
|
||||
|
||||
if (wantConnected) {
|
||||
onStatus("Reconnecting in ${backoffMillis / 1000}s...")
|
||||
ircDebug(config.id, "reconnecting in ${backoffMillis}ms")
|
||||
delay(backoffMillis)
|
||||
backoffMillis = (backoffMillis * 2).coerceAtMost(60_000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRawLine(line: String) {
|
||||
// Used to log every single raw line here (RECV: "...") - removed. It was the
|
||||
// single highest-volume debug line in the app: one per incoming line, every line,
|
||||
// forever, and println is synchronized/blocking - a real, measurable cost in a
|
||||
// busy channel, not just noise. The specific 001/376/422 confirmations below are
|
||||
// once-per-connect and stay.
|
||||
if (line.startsWith("PING")) {
|
||||
val token = line.substringAfter("PING ")
|
||||
send("PONG $token")
|
||||
return
|
||||
}
|
||||
val parsed = parseIrcLine(line) ?: return
|
||||
|
||||
// CTCP requests arrive as a PRIVMSG wrapped in \x01...\x01. VERSION gets
|
||||
// handled right here so it never shows up in a channel/PM window - real
|
||||
// clients answer these silently too.
|
||||
if (parsed.command == "PRIVMSG") {
|
||||
val text = parsed.params.getOrNull(1)
|
||||
if (text != null && text.startsWith("") && text.endsWith("") && text.length > 1) {
|
||||
val ctcpBody = text.removePrefix("").removeSuffix("")
|
||||
val ctcpCommand = ctcpBody.substringBefore(' ').uppercase()
|
||||
if (ctcpCommand == "VERSION") {
|
||||
val replyTo = nickFromPrefix(parsed.prefix)
|
||||
if (replyTo != null) {
|
||||
send("NOTICE $replyTo :VERSION It's an irc client, fucko!")
|
||||
}
|
||||
return
|
||||
}
|
||||
// Other CTCP types (ACTION, PING, etc.) fall through to onLine as normal -
|
||||
// ACTION in particular still needs to reach ConnectionManager to render as /me.
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.command == "001") {
|
||||
ircDebug(config.id, "registered (001) - awaiting end-of-MOTD (376/422) before perform+rejoin")
|
||||
}
|
||||
if (parsed.command == "376" || parsed.command == "422") {
|
||||
runPostRegistration()
|
||||
}
|
||||
onLine(parsed)
|
||||
}
|
||||
|
||||
// Runs the on-connect ("perform") commands - NickServ/ChanServ identify, etc. - then
|
||||
// rejoins every channel we were actually in (the configured auto-join list plus
|
||||
// anything joined manually mid-session, so a reconnect doesn't forget those). Fired
|
||||
// off end-of-MOTD rather than bare 001 - see the comment in connect() for why - so
|
||||
// identify commands land once the session is actually settled enough for services to
|
||||
// reliably act on them.
|
||||
private fun runPostRegistration() {
|
||||
if (registeredHandled) return
|
||||
registeredHandled = true
|
||||
// The whole sequence (identify, then rejoin) runs inside this delayed coroutine
|
||||
// rather than just delaying the identify send - keeping both here preserves "run
|
||||
// identify before joining anything that might require it" (rejoin used to run
|
||||
// synchronously right after, which would otherwise now race ahead of a delayed
|
||||
// identify send and reach the wire first). The delay itself is a few extra hundred
|
||||
// ms of insurance on top of an already-real event (376/422) rather than a fixed
|
||||
// guess from connection time - some networks' NickServ genuinely isn't ready to
|
||||
// process a command in the same instant MOTD ends.
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(POST_MOTD_IDENTIFY_DELAY_MS)
|
||||
config.onConnectCommands.forEach { cmd ->
|
||||
if (cmd.isNotBlank()) send(translateOnConnectCommand(cmd))
|
||||
}
|
||||
val rejoinList = (config.autoJoinChannels + joinedChannels).distinct()
|
||||
ircDebug(config.id, "post-registration - rejoining: $rejoinList")
|
||||
rejoinList.forEach { ch -> joinChannel(ch) }
|
||||
}
|
||||
}
|
||||
|
||||
fun send(raw: String) {
|
||||
outbox.trySend(raw)
|
||||
}
|
||||
|
||||
fun sendMessage(target: String, text: String) {
|
||||
send("PRIVMSG $target :$text")
|
||||
}
|
||||
|
||||
fun joinChannel(channel: String) {
|
||||
joinedChannels.add(channel)
|
||||
ircDebug(config.id, "joinChannel($channel) - tracked set now: $joinedChannels")
|
||||
send("JOIN $channel")
|
||||
}
|
||||
|
||||
fun partChannel(channel: String, reason: String = "") {
|
||||
joinedChannels.remove(channel)
|
||||
ircDebug(config.id, "partChannel($channel) - tracked set now: $joinedChannels")
|
||||
send("PART $channel :$reason")
|
||||
}
|
||||
fun kick(channel: String, nick: String, reason: String = "") = send("KICK $channel $nick :$reason")
|
||||
fun setMode(channel: String, modeChange: String, nick: String) = send("MODE $channel $modeChange $nick")
|
||||
fun ban(channel: String, mask: String) = send("MODE $channel +b $mask")
|
||||
|
||||
fun disconnect() {
|
||||
wantConnected = false
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try { writer?.println("QUIT :leaving") } catch (_: Exception) {}
|
||||
readJob?.cancel()
|
||||
closeSocketQuietly()
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeSocketQuietly() {
|
||||
try { socket?.close() } catch (_: Exception) {}
|
||||
socket = null
|
||||
writer = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package irc
|
||||
|
||||
/** A minimally-parsed raw IRC protocol line. */
|
||||
data class ParsedLine(
|
||||
val prefix: String?, // e.g. "nick!user@host" or a server name
|
||||
val command: String, // e.g. "PRIVMSG", "JOIN", "376", etc.
|
||||
val params: List<String> // trailing param (after ':') is params.last() if present
|
||||
)
|
||||
|
||||
fun parseIrcLine(line: String): ParsedLine? {
|
||||
if (line.isBlank()) return null
|
||||
var rest = line
|
||||
var prefix: String? = null
|
||||
|
||||
if (rest.startsWith(":")) {
|
||||
val spaceIdx = rest.indexOf(' ')
|
||||
if (spaceIdx == -1) return null
|
||||
prefix = rest.substring(1, spaceIdx)
|
||||
rest = rest.substring(spaceIdx + 1)
|
||||
}
|
||||
|
||||
val trailingSplit = rest.indexOf(" :")
|
||||
val paramsStr: String
|
||||
val trailing: String?
|
||||
if (trailingSplit != -1) {
|
||||
paramsStr = rest.substring(0, trailingSplit)
|
||||
trailing = rest.substring(trailingSplit + 2)
|
||||
} else {
|
||||
paramsStr = rest
|
||||
trailing = null
|
||||
}
|
||||
|
||||
val parts = paramsStr.split(" ").filter { it.isNotEmpty() }
|
||||
if (parts.isEmpty()) return null
|
||||
val command = parts[0].uppercase()
|
||||
val params = parts.drop(1).toMutableList()
|
||||
if (trailing != null) params.add(trailing)
|
||||
|
||||
return ParsedLine(prefix, command, params)
|
||||
}
|
||||
|
||||
/** Extracts just the nick portion from an IRC prefix like "nick!user@host". */
|
||||
fun nickFromPrefix(prefix: String?): String? {
|
||||
if (prefix == null) return null
|
||||
val bangIdx = prefix.indexOf('!')
|
||||
return if (bangIdx != -1) prefix.substring(0, bangIdx) else prefix
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package irc
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
|
||||
// Standard 16-color mIRC palette (index 0-15).
|
||||
val mircPalette = listOf(
|
||||
Color(0xFFFFFFFF), // 0 white
|
||||
Color(0xFF000000), // 1 black
|
||||
Color(0xFF00007F), // 2 blue (navy)
|
||||
Color(0xFF009300), // 3 green
|
||||
Color(0xFFFF0000), // 4 red
|
||||
Color(0xFF7F0000), // 5 brown (maroon)
|
||||
Color(0xFF9C009C), // 6 purple
|
||||
Color(0xFFFC7F00), // 7 orange
|
||||
Color(0xFFFFFF00), // 8 yellow
|
||||
Color(0xFF00FC00), // 9 light green
|
||||
Color(0xFF00939C), // 10 cyan
|
||||
Color(0xFF00FFFF), // 11 light cyan
|
||||
Color(0xFF0000FC), // 12 light blue
|
||||
Color(0xFFFF00FF), // 13 pink
|
||||
Color(0xFF7F7F7F), // 14 grey
|
||||
Color(0xFFD2D2D2) // 15 light grey
|
||||
)
|
||||
|
||||
private const val BOLD = ''
|
||||
private const val COLOR = ''
|
||||
private const val RESET = ''
|
||||
private const val REVERSE = ''
|
||||
private const val UNDERLINE = ''
|
||||
|
||||
private const val ITALIC = ''
|
||||
|
||||
private val urlRegex = Regex("""https?://\S+""")
|
||||
private val urlColor = Color(0xFF4EA1FF)
|
||||
|
||||
private val imageUrlRegex = Regex("""https?://[^\s]+""")
|
||||
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
|
||||
|
||||
/**
|
||||
* Extracts URLs from raw message text that look like they point at an image,
|
||||
* based on the file extension in the path (ignoring query string/fragment).
|
||||
*/
|
||||
fun extractImageUrls(text: String): List<String> {
|
||||
return imageUrlRegex.findAll(text).map { it.value }.filter { url ->
|
||||
val cleanUrl = url.substringBefore('?').substringBefore('#')
|
||||
val ext = cleanUrl.substringAfterLast('.', "").lowercase()
|
||||
ext in imageExtensions
|
||||
}.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a raw IRC line (which may contain mIRC control codes) into an AnnotatedString
|
||||
* ready to render in Compose. baseColor is applied where no explicit foreground is set,
|
||||
* so it follows the user's chosen black/white text setting.
|
||||
*/
|
||||
fun parseMircToAnnotatedString(raw: String, baseColor: Color): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
|
||||
var bold = false
|
||||
var underline = false
|
||||
var italic = false
|
||||
var reverse = false
|
||||
var fg: Color? = null
|
||||
var bg: Color? = null
|
||||
|
||||
fun currentStyle(): SpanStyle {
|
||||
val effectiveFg = if (reverse) (bg ?: baseColor) else (fg ?: baseColor)
|
||||
val effectiveBg = if (reverse) (fg ?: baseColor) else bg
|
||||
return SpanStyle(
|
||||
color = effectiveFg,
|
||||
background = effectiveBg ?: Color.Unspecified,
|
||||
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
|
||||
fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal,
|
||||
textDecoration = if (underline) TextDecoration.Underline else TextDecoration.None
|
||||
)
|
||||
}
|
||||
|
||||
var i = 0
|
||||
var runStart = 0
|
||||
|
||||
fun flush(endExclusive: Int) {
|
||||
if (endExclusive > runStart) {
|
||||
builder.withStyle(currentStyle()) {
|
||||
append(raw.substring(runStart, endExclusive))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun readColorDigits(startIdx: Int): Pair<Int?, Int> {
|
||||
// Reads up to 2 digits starting at startIdx, returns (colorIndexOrNull, nextIndex)
|
||||
var idx = startIdx
|
||||
var numStr = ""
|
||||
while (idx < raw.length && raw[idx].isDigit() && numStr.length < 2) {
|
||||
numStr += raw[idx]
|
||||
idx++
|
||||
}
|
||||
if (numStr.isEmpty()) return Pair(null, startIdx)
|
||||
val n = numStr.toInt().coerceIn(0, 15)
|
||||
return Pair(n, idx)
|
||||
}
|
||||
|
||||
while (i < raw.length) {
|
||||
when (raw[i]) {
|
||||
BOLD -> {
|
||||
flush(i); bold = !bold; i++; runStart = i
|
||||
}
|
||||
UNDERLINE -> {
|
||||
flush(i); underline = !underline; i++; runStart = i
|
||||
}
|
||||
ITALIC -> {
|
||||
flush(i); italic = !italic; i++; runStart = i
|
||||
}
|
||||
REVERSE -> {
|
||||
flush(i); reverse = !reverse; i++; runStart = i
|
||||
}
|
||||
RESET -> {
|
||||
flush(i)
|
||||
bold = false; underline = false; italic = false; reverse = false; fg = null; bg = null
|
||||
i++; runStart = i
|
||||
}
|
||||
COLOR -> {
|
||||
flush(i)
|
||||
i++
|
||||
val (fgIdx, nextI) = readColorDigits(i)
|
||||
i = nextI
|
||||
if (fgIdx == null) {
|
||||
fg = null; bg = null
|
||||
} else {
|
||||
fg = mircPalette[fgIdx]
|
||||
if (i < raw.length && raw[i] == ',') {
|
||||
val (bgIdx, nextI2) = readColorDigits(i + 1)
|
||||
if (bgIdx != null) {
|
||||
bg = mircPalette[bgIdx]
|
||||
i = nextI2
|
||||
}
|
||||
}
|
||||
}
|
||||
runStart = i
|
||||
}
|
||||
else -> i++
|
||||
}
|
||||
}
|
||||
flush(raw.length)
|
||||
|
||||
val parsed = builder.toAnnotatedString()
|
||||
val matches = urlRegex.findAll(parsed.text).toList()
|
||||
if (matches.isEmpty()) return parsed
|
||||
|
||||
// Layer link styling/annotations on top of the existing mIRC-colored string rather
|
||||
// than replacing it, so control codes elsewhere in the line are unaffected.
|
||||
val linked = AnnotatedString.Builder(parsed)
|
||||
matches.forEach { match ->
|
||||
val start = match.range.first
|
||||
val end = match.range.last + 1
|
||||
linked.addStyle(SpanStyle(color = urlColor, textDecoration = TextDecoration.Underline), start, end)
|
||||
linked.addStringAnnotation(tag = "URL", annotation = match.value, start = start, end = end)
|
||||
}
|
||||
return linked.toAnnotatedString()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
||||
package ui
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import data.AliasRepository
|
||||
import data.BufferKey
|
||||
import data.WindowDisplaySettings
|
||||
import data.WindowPrefsRepository
|
||||
import data.isChannelName
|
||||
import data.storageId
|
||||
import irc.ConnectionManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Android, just using Compose Desktop's native multi-Window() support instead of the
|
||||
* Activity/freeform-window plumbing Android needed.
|
||||
*/
|
||||
@Composable
|
||||
fun ChannelWindow(
|
||||
bufferKey: BufferKey,
|
||||
prefsRepo: WindowPrefsRepository,
|
||||
colorScheme: ColorScheme,
|
||||
onOpenBuffer: (BufferKey) -> Unit,
|
||||
onCloseSelf: () -> Unit,
|
||||
onQuitApp: () -> Unit
|
||||
) {
|
||||
val serverId = bufferKey.serverId
|
||||
val channelName = bufferKey.name
|
||||
val windowTitle = if (isChannelName(channelName)) channelName else "PM: $channelName"
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val displaySettings by prefsRepo.settingsFor(bufferKey.storageId()).collectAsState()
|
||||
// A previous pass replaced this with a hand-rolled Flow.map{}.distinctUntilChanged()
|
||||
// pipeline scoped per-window, reasoning that collecting the WHOLE map here (as every
|
||||
// open window does) causes every window to recompose on every single incoming
|
||||
// message anywhere, not just its own - true, but that's a performance concern, and
|
||||
// the auto-scroll bug that pipeline was ALSO supposed to help fix persisted across
|
||||
// multiple attempts regardless. A hand-chained Flow pipeline is real reactive-system
|
||||
// surface area living outside Compose's own snapshot system, bridged back in via
|
||||
// collectAsState - exactly the kind of thing that's hard to fully verify by reading
|
||||
// alone. Reverted to the plain, standard, well-tested collectAsState() + derivedStateOf
|
||||
// pattern: still avoids recomposing anything that only reads `buffer` when an
|
||||
// unrelated buffer changes (derivedStateOf's whole purpose), but goes through
|
||||
// Compose's own primitives end to end instead of a separate hand-built one.
|
||||
val buffers by ConnectionManager.buffers.collectAsState()
|
||||
val buffer by remember(bufferKey) { derivedStateOf { buffers[bufferKey] } }
|
||||
val ownNicks by ConnectionManager.ownNicksFlow.collectAsState()
|
||||
val ownNick by remember(serverId) { derivedStateOf { ownNicks[serverId] } }
|
||||
val windowState = rememberWindowState()
|
||||
|
||||
// undecorated + transparent trade the native OS title bar for real per-pixel window
|
||||
// transparency (Windows only honors transparency on undecorated windows) - ChannelScreen
|
||||
// supplies its own title bar with minimize/close/drag/resize to make up for what the
|
||||
// OS chrome would otherwise provide. No Surface wrapper here (M3 Surface paints an
|
||||
// opaque background) - ChannelScreen already paints its own alpha-aware background.
|
||||
Window(
|
||||
onCloseRequest = onCloseSelf,
|
||||
title = windowTitle,
|
||||
icon = painterResource("icon.png"),
|
||||
state = windowState,
|
||||
undecorated = true,
|
||||
transparent = true,
|
||||
resizable = true
|
||||
) {
|
||||
MaterialTheme(colorScheme = colorScheme) {
|
||||
ChannelScreen(
|
||||
windowState = windowState,
|
||||
onCloseWindow = onCloseSelf,
|
||||
channelName = windowTitle,
|
||||
buffer = buffer,
|
||||
displaySettings = displaySettings,
|
||||
colorScheme = colorScheme,
|
||||
ownNick = ownNick,
|
||||
onSend = { text ->
|
||||
if (text.startsWith("/")) {
|
||||
val withoutSlash = text.substring(1)
|
||||
val spaceIdx = withoutSlash.indexOf(' ')
|
||||
val command = (if (spaceIdx >= 0) withoutSlash.substring(0, spaceIdx) else withoutSlash).uppercase()
|
||||
val rawArgs = if (spaceIdx >= 0) withoutSlash.substring(spaceIdx + 1) else ""
|
||||
val args = rawArgs.trim()
|
||||
|
||||
// App-level commands are checked before any IRC command handling,
|
||||
// and identically in every window (global, not per-server/channel):
|
||||
// /reload is reserved and can't be shadowed by an aliases.txt entry
|
||||
// (AliasRepository never loads one named "reload"); alias lookup
|
||||
// comes next so a matching alias runs locally instead of hitting
|
||||
// the built-in ME/NICK/PART/etc. handling or a raw passthrough.
|
||||
if (command == "RELOAD") {
|
||||
val count = AliasRepository.reload()
|
||||
ConnectionManager.addSystemMessage(serverId, channelName, "Aliases reloaded ($count aliases loaded)")
|
||||
} else if (AliasRepository.tryRunAlias(command, rawArgs)) {
|
||||
// Alias handled locally (opened a path/url or launched a process) - nothing sent to the server.
|
||||
} else {
|
||||
when (command) {
|
||||
"ME" -> ConnectionManager.sendChannelAction(serverId, channelName, args)
|
||||
"NICK" -> ConnectionManager.sendRaw(serverId, "NICK $args")
|
||||
"PART", "EXIT", "CLOSE" -> {
|
||||
val target = args.ifBlank { channelName }
|
||||
ConnectionManager.connectionFor(serverId)?.partChannel(target, "leaving")
|
||||
if (args.isBlank() || target == channelName) onCloseSelf()
|
||||
}
|
||||
"QUIT" -> {
|
||||
if (rawArgs.isBlank()) {
|
||||
ConnectionManager.disconnectAll()
|
||||
onQuitApp()
|
||||
} else {
|
||||
ConnectionManager.sendChannelMessage(serverId, channelName, rawArgs)
|
||||
}
|
||||
}
|
||||
"JOIN" -> {
|
||||
ConnectionManager.sendRaw(serverId, "JOIN $args")
|
||||
onOpenBuffer(BufferKey(serverId, args))
|
||||
}
|
||||
"MSG" -> {
|
||||
val msgSpaceIdx = args.indexOf(' ')
|
||||
if (msgSpaceIdx >= 0) {
|
||||
val target = args.substring(0, msgSpaceIdx)
|
||||
val message = args.substring(msgSpaceIdx + 1)
|
||||
ConnectionManager.sendChannelMessage(serverId, target, message)
|
||||
}
|
||||
}
|
||||
else -> ConnectionManager.sendRaw(serverId, "$command $args".trim())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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) },
|
||||
onAlphaChange = { alpha ->
|
||||
scope.launch(Dispatchers.IO) { prefsRepo.setAlpha(bufferKey.storageId(), alpha) }
|
||||
},
|
||||
onTextColorChange = { index ->
|
||||
scope.launch(Dispatchers.IO) { prefsRepo.setTextColorMircIndex(bufferKey.storageId(), index) }
|
||||
},
|
||||
onFontSizeChange = { size ->
|
||||
scope.launch(Dispatchers.IO) { prefsRepo.setFontSize(bufferKey.storageId(), size) }
|
||||
},
|
||||
onShowUserListChange = { show ->
|
||||
scope.launch(Dispatchers.IO) { prefsRepo.setShowUserList(bufferKey.storageId(), show) }
|
||||
},
|
||||
onNickColorsToggle = { enabled ->
|
||||
scope.launch(Dispatchers.IO) { prefsRepo.setNickColorsEnabled(bufferKey.storageId(), enabled) }
|
||||
},
|
||||
onMentionHighlightColorChange = { colorArgb ->
|
||||
scope.launch(Dispatchers.IO) { prefsRepo.setMentionHighlightColor(bufferKey.storageId(), colorArgb) }
|
||||
},
|
||||
onShowImagePreviewsChange = { show ->
|
||||
scope.launch(Dispatchers.IO) { prefsRepo.setShowImagePreviews(bufferKey.storageId(), show) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.isShiftPressed
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogWindow
|
||||
import androidx.compose.ui.window.rememberDialogState
|
||||
import data.ServerConfig
|
||||
import data.ServerRepository
|
||||
import irc.ConnectionManager
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
repo: ServerRepository,
|
||||
colorScheme: ColorScheme,
|
||||
onOpenChannel: (serverId: String, channelName: String) -> Unit,
|
||||
onExitApp: () -> Unit
|
||||
) {
|
||||
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>()) }
|
||||
var channelInputs by remember { mutableStateOf(mapOf<String, String>()) }
|
||||
var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("wIRC", style = MaterialTheme.typography.headlineSmall)
|
||||
Row {
|
||||
Button(onClick = { editingServer = null; showAddDialog = true }) {
|
||||
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.
|
||||
TextButton(onClick = onExitApp) {
|
||||
Text("Exit wIRC")
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (servers.isEmpty()) {
|
||||
Text("No saved servers yet. Click \"+ Add Server\" to set one up.")
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
items(servers) { server ->
|
||||
ServerCard(
|
||||
server = server,
|
||||
isConnected = server.id in connectedIds,
|
||||
channelInput = channelInputs[server.id] ?: "#",
|
||||
openedChannels = openedChannels[server.id] ?: emptyList(),
|
||||
onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) },
|
||||
onConnect = {
|
||||
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
|
||||
// "Join + Open" flow does - so give the server a moment to
|
||||
// actually join us before opening windows for them.
|
||||
coroutineScope.launch {
|
||||
delay(500)
|
||||
server.autoJoinChannels.forEach { ch ->
|
||||
val current = openedChannels[server.id] ?: emptyList()
|
||||
if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch))
|
||||
onOpenChannel(server.id, ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onEdit = { editingServer = server; showAddDialog = true },
|
||||
onDelete = {
|
||||
repo.remove(server.id)
|
||||
servers = repo.loadAll()
|
||||
},
|
||||
onJoinAndOpen = { ch ->
|
||||
if (ch.isNotBlank()) {
|
||||
ConnectionManager.connectionFor(server.id)?.joinChannel(ch)
|
||||
val current = openedChannels[server.id] ?: emptyList()
|
||||
if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch))
|
||||
onOpenChannel(server.id, ch)
|
||||
}
|
||||
},
|
||||
onReopenWindow = { ch ->
|
||||
// Re-send JOIN even though joinedChannels *should* already have
|
||||
// rejoined this channel on any reconnect while the window was
|
||||
// closed. JOIN is a no-op on the server if we're already in the
|
||||
// channel, so this costs nothing - but it means "reopen" can never
|
||||
// produce a window that looks open while the connection silently
|
||||
// isn't actually in the channel (e.g. if a reconnect's rejoin was
|
||||
// dropped, delayed, or raced - see IrcConnection's outbox/ircDebug
|
||||
// logging). Same guarantee onJoinAndOpen already had.
|
||||
ConnectionManager.connectionFor(server.id)?.joinChannel(ch)
|
||||
onOpenChannel(server.id, ch)
|
||||
}
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddDialog) {
|
||||
ServerEditDialog(
|
||||
existing = editingServer,
|
||||
colorScheme = colorScheme,
|
||||
onDismiss = { showAddDialog = false },
|
||||
onSave = { config ->
|
||||
repo.upsert(config)
|
||||
servers = repo.loadAll()
|
||||
showAddDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerCard(
|
||||
server: ServerConfig,
|
||||
isConnected: Boolean,
|
||||
channelInput: String,
|
||||
openedChannels: List<String>,
|
||||
onChannelInputChange: (String) -> Unit,
|
||||
onConnect: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onJoinAndOpen: (String) -> Unit,
|
||||
onReopenWindow: (String) -> Unit
|
||||
) {
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(server.label, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"${server.host}:${server.port}${if (server.useTls) " (TLS)" else ""} · nick: ${server.nick}",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
if (server.onConnectCommands.isNotEmpty()) {
|
||||
Text(
|
||||
"${server.onConnectCommands.size} on-connect command(s) saved",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
Row {
|
||||
TextButton(onClick = onEdit) { Text("Edit") }
|
||||
TextButton(onClick = onDelete) { Text("Delete") }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (!isConnected) {
|
||||
Button(onClick = onConnect) { Text("Connect") }
|
||||
} else {
|
||||
Text("Connected", style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row {
|
||||
OutlinedTextField(
|
||||
value = channelInput,
|
||||
onValueChange = onChannelInputChange,
|
||||
label = { Text("Channel") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = { onJoinAndOpen(channelInput.trim()) }) {
|
||||
Text("Join + Open")
|
||||
}
|
||||
}
|
||||
if (openedChannels.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Windows:", style = MaterialTheme.typography.labelSmall)
|
||||
openedChannels.forEach { ch ->
|
||||
TextButton(onClick = { onReopenWindow(ch) }) { Text(ch) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerEditDialog(
|
||||
existing: ServerConfig?,
|
||||
colorScheme: ColorScheme,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (ServerConfig) -> Unit
|
||||
) {
|
||||
var label by remember { mutableStateOf(existing?.label ?: "") }
|
||||
var host by remember { mutableStateOf(existing?.host ?: "") }
|
||||
var port by remember { mutableStateOf((existing?.port ?: 6667).toString()) }
|
||||
var useTls by remember { mutableStateOf(existing?.useTls ?: false) }
|
||||
var nick by remember { mutableStateOf(existing?.nick ?: "") }
|
||||
var autoJoin by remember { mutableStateOf(existing?.autoJoinChannels?.joinToString(", ") ?: "") }
|
||||
// One raw IRC command per line, e.g.:
|
||||
// PRIVMSG NickServ :IDENTIFY hunter2
|
||||
var onConnectCommands by remember {
|
||||
mutableStateOf(existing?.onConnectCommands?.joinToString("\n") ?: "")
|
||||
}
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
fun save() {
|
||||
val id = existing?.id ?: host.replace(Regex("[^a-zA-Z0-9]"), "_")
|
||||
onSave(
|
||||
ServerConfig(
|
||||
id = id,
|
||||
label = label.ifBlank { host },
|
||||
host = host,
|
||||
port = port.toIntOrNull() ?: 6667,
|
||||
useTls = useTls,
|
||||
nick = nick.ifBlank { "guest${(1000..9999).random()}" },
|
||||
autoJoinChannels = autoJoin.split(",").map { it.trim() }.filter { it.isNotBlank() },
|
||||
// This dialog has no SASL fields of its own (SASL is currently only
|
||||
// settable by hand-editing servers.json), so without carrying these
|
||||
// over explicitly, saving any OTHER field here - like fixing an
|
||||
// identify command - would silently null out an existing server's
|
||||
// saslUser/saslPass on every edit.
|
||||
saslUser = existing?.saslUser,
|
||||
saslPass = existing?.saslPass,
|
||||
onConnectCommands = onConnectCommands.split("\n").map { it.trim() }.filter { it.isNotBlank() }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// A real, separate top-level window (DialogWindow), not a material3 AlertDialog. An
|
||||
// AlertDialog on this Compose Multiplatform Desktop target renders as an overlay layer
|
||||
// within the calling window's own canvas (a ComposeSceneLayer, not a native window of
|
||||
// its own), so its outer bounds are hard-clipped to whatever size the main wIRC window
|
||||
// currently has - the previous fix here (heightIn(max=420.dp) + an internal scroll on
|
||||
// just the fields Column) only ever addressed the fields overflowing THEIR OWN space; it
|
||||
// did nothing for a main window smaller than that, which could still clip the dialog's
|
||||
// outer chrome outright. A DialogWindow is a genuine native OS window, sized and
|
||||
// positioned on its own terms, so the main window's current size can no longer clip it -
|
||||
// the same underlying fix as the channel-window settings panel (see ChannelScreen).
|
||||
DialogWindow(
|
||||
onCloseRequest = onDismiss,
|
||||
title = if (existing == null) "Add Server" else "Edit Server",
|
||||
state = rememberDialogState(size = DpSize(460.dp, 720.dp)),
|
||||
resizable = true
|
||||
) {
|
||||
MaterialTheme(colorScheme = colorScheme) {
|
||||
Surface {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.Tab) {
|
||||
focusManager.moveFocus(if (event.isShiftPressed) FocusDirection.Up else FocusDirection.Down)
|
||||
true
|
||||
} else false
|
||||
}
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = label,
|
||||
onValueChange = { label = it },
|
||||
label = { Text("Display name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = { host = it },
|
||||
label = { Text("Host") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = port,
|
||||
onValueChange = { port = it },
|
||||
label = { Text("Port") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = useTls, onCheckedChange = { useTls = it })
|
||||
Text("Use TLS")
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = nick,
|
||||
onValueChange = { nick = it },
|
||||
label = { Text("Nick") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = autoJoin,
|
||||
onValueChange = { autoJoin = it },
|
||||
label = { Text("Auto-join channels (comma separated)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = onConnectCommands,
|
||||
onValueChange = { onConnectCommands = it },
|
||||
label = { Text("On-connect / identify commands, one per line") },
|
||||
placeholder = { Text("PRIVMSG NickServ :IDENTIFY yourpassword") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
|
||||
)
|
||||
Text(
|
||||
"Saved and sent automatically every time you connect, once the server " +
|
||||
"finishes sending its MOTD and before auto-join. See the code comment " +
|
||||
"on ServerRepository for a note on how this is (and isn't) protected at rest.",
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
TextButton(onClick = { save() }, enabled = host.isNotBlank()) { Text("Save") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Reference in New Issue
Block a user