Initial commit
This commit is contained in:
@@ -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}"
|
||||
Reference in New Issue
Block a user