/quit now disconnects the window's server without closing anything: every buffer of that server and wIRC main get a "Disconnected" line, the server card shows Disconnected with its Connect button, history is flushed to disk, and there's no auto-reconnect. Connect rejoins the channels we were in (including ones joined with /join, now tracked) into the still-open windows. Sending while disconnected shows a "Not connected" line in that window. Exit wIRC still disconnects everything and closes the app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
210 lines
9.6 KiB
Kotlin
210 lines
9.6 KiB
Kotlin
package data
|
|
|
|
import java.awt.Desktop
|
|
import java.io.File
|
|
import java.net.URI
|
|
import java.net.URLEncoder
|
|
|
|
// 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
|
|
}
|
|
|
|
/** True if [command] (without the leading '/') is an alias - those run locally, no server needed. */
|
|
fun hasAlias(command: String): Boolean {
|
|
val key = command.lowercase()
|
|
return key != RESERVED_RELOAD && key in aliases
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
val key = command.lowercase()
|
|
if (key == RESERVED_RELOAD) return false
|
|
val action = aliases[key] ?: return false
|
|
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
|
|
if (target.isBlank()) return
|
|
|
|
try {
|
|
when (type) {
|
|
"path" -> Desktop.getDesktop().open(File(target))
|
|
"url" -> Desktop.getDesktop().browse(URI(target))
|
|
// Routed through "cmd /c start" (same launch path as double-clicking in
|
|
// Explorer or the Run dialog) rather than exec'd directly. A direct
|
|
// ProcessBuilder(target).start() calls CreateProcess, which does NOT honor
|
|
// 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" -> 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
|
|
}
|
|
}
|
|
}
|