Initial commit

This commit is contained in:
weishaupt
2026-09-01 12:32:15 -04:00
commit defdefdb28
33 changed files with 3540 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
package data
import java.awt.Desktop
import java.io.File
import java.net.URI
import java.net.URLEncoder
// TEMP DEBUG (see task: alias multi-word arguments investigation) - remove once confirmed
// fixed. Traces the full alias pipeline: raw input -> matched alias -> substituted target
// -> the actual Desktop API call, so a silent failure anywhere in that chain is visible.
private fun aliasDebug(msg: String) {
println("[alias-debug] $msg")
}
// Name reserved for the app-level /reload command (ChannelWindow.kt handles it directly,
// before alias lookup even runs) - kept here too so a line in aliases.txt can never define
// an alias that would shadow it.
private const val RESERVED_RELOAD = "reload"
private val defaultAliasesContent = """
# wIRC alias commands. One alias per line: /name = type:target
# path:X opens X (a file or folder) in Explorer
# url:X opens X in the default browser
# run:X launches X as a process
# {arg} in the target is replaced with whatever text follows the command.
/c = path:C:\
/blog = url:https://bloggin.space
/url = url:{arg}
""".trimIndent() + "\n"
/**
* Global (not per-server/per-channel) alias command table, loaded once at app startup
* (see Main.kt) and shared by every ChannelWindow's command router.
*
* aliases.txt is bundled into the install directory by build.gradle.kts's
* appResourcesRootDir (see installerResources/aliases.txt), which puts it somewhere under
* Program Files - normally UAC-protected. On [init], this repository figures out whether
* that bundled copy can actually be hand-edited in place; if not, it falls back to a
* writable copy under %APPDATA%\wIRC instead and uses that from then on.
*/
object AliasRepository {
private var aliases: Map<String, String> = emptyMap()
lateinit var activeFile: File
private set
fun init() {
activeFile = resolveAliasFile()
aliases = loadAliases(activeFile)
println("[AliasRepository] Using aliases file: ${activeFile.absolutePath} (${aliases.size} aliases loaded)")
}
/** Re-reads aliases.txt from the currently active path. Returns the number of aliases loaded. */
fun reload(): Int {
aliases = loadAliases(activeFile)
return aliases.size
}
/**
* Returns true if [command] (without the leading '/') matched a known alias and its
* action was run - callers should treat that as "handled, don't fall through to IRC
* command handling". Returns false for unknown commands (including the reserved
* "reload" name, which this repository never loads as an alias).
*/
fun tryRunAlias(command: String, args: String): Boolean {
aliasDebug("tryRunAlias: command=\"$command\" args=\"$args\"")
val key = command.lowercase()
if (key == RESERVED_RELOAD) return false
val action = aliases[key]
if (action == null) {
aliasDebug("no alias matched for \"$key\"")
return false
}
aliasDebug("matched \"$key\" -> \"$action\"")
runAction(action, args)
return true
}
private fun runAction(action: String, args: String) {
val colonIdx = action.indexOf(':')
if (colonIdx < 0) return
val type = action.substring(0, colonIdx).trim()
val rawTarget = action.substring(colonIdx + 1).trim()
// URL targets need their {arg} substitution percent-encoded - java.net.URI's
// single-String constructor does strict RFC 2396 parsing and throws
// URISyntaxException on a raw space (e.g. "/src alexis road" against
// "url:...?search={arg}" produces "...?search=alexis road", which URI() rejects
// outright). That exception used to be swallowed by the catch below with only a
// println - invisible in a windowed app with no attached console, so a multi-word
// arg looked like the alias silently did nothing. path/run targets are real
// filesystem paths or process args, so they're left unencoded - a space there is
// literal, not a URL delimiter.
val substitutedArg = if (type == "url") encodeArgForUrl(args) else args
val target = if (rawTarget.contains("{arg}")) rawTarget.replace("{arg}", substitutedArg) else rawTarget
aliasDebug("type=\"$type\" rawTarget=\"$rawTarget\" substitutedArg=\"$substitutedArg\" -> target=\"$target\"")
if (target.isBlank()) return
try {
when (type) {
"path" -> { aliasDebug("Desktop.open(File(\"$target\"))"); Desktop.getDesktop().open(File(target)) }
"url" -> { aliasDebug("Desktop.browse(URI(\"$target\"))"); Desktop.getDesktop().browse(URI(target)) }
// Routed through "cmd /c start" (same launch path as double-clicking in
// Explorer or the Run dialog) rather than exec'd directly. A direct
// ProcessBuilder(target).start() calls CreateProcess, which does NOT honor
// an exe's embedded "requireAdministrator" manifest - it just fails with
// CreateProcess error 740 (ERROR_ELEVATION_REQUIRED). "start" goes through
// ShellExecute instead, which shows the same UAC prompt a normal launch
// would. This also preserves bare-name resolution via Windows' App Paths
// registry / PATH - not guaranteed to work for every app, since it depends
// on that app having registered itself with Windows.
"run" -> { aliasDebug("ProcessBuilder start \"$target\""); ProcessBuilder("cmd", "/c", "start", "\"\"", target).start() }
else -> println("[AliasRepository] Unknown alias action type '$type' in \"$action\"")
}
} catch (e: Exception) {
println("[AliasRepository] Failed to run alias action \"$action\" (target=\"$target\"): ${e.message}")
}
}
// Percent-encodes only what would actually break URI parsing or misrepresent the
// argument (whitespace, quotes, non-ASCII, etc.), matching the standard
// application/x-www-form-urlencoded convention of encoding spaces as '+' (the ticket's
// expected "search=alexis+road" output). Structural/reserved URI characters
// (: / ? # & = etc.) are passed through unencoded so an alias like "/url = url:{arg}"
// still works when {arg} is itself a full URL rather than a query value.
private fun encodeArgForUrl(args: String): String {
val safe = "-_.~:/?#[]@!$&'()*+,;=%"
val sb = StringBuilder()
for (c in args) {
when {
c == ' ' -> sb.append('+')
c.isLetterOrDigit() && c.code < 128 -> sb.append(c)
c in safe -> sb.append(c)
else -> sb.append(URLEncoder.encode(c.toString(), "UTF-8"))
}
}
return sb.toString()
}
private fun loadAliases(file: File): Map<String, String> {
if (!file.exists()) return emptyMap()
val result = mutableMapOf<String, String>()
file.readLines().forEach { line ->
val trimmed = line.trim()
if (trimmed.isEmpty() || trimmed.startsWith("#")) return@forEach
val eqIdx = trimmed.indexOf('=')
if (eqIdx < 0) return@forEach
val name = trimmed.substring(0, eqIdx).trim().removePrefix("/").lowercase()
val action = trimmed.substring(eqIdx + 1).trim()
if (name.isEmpty() || action.isEmpty() || name == RESERVED_RELOAD) return@forEach
result[name] = action
}
return result
}
private fun resolveAliasFile(): File {
val appDataFile = File(File(appDataRoot(), "wIRC"), "aliases.txt")
val resourcesDirProp = System.getProperty("compose.application.resources.dir")
if (resourcesDirProp != null) {
val bundled = File(resourcesDirProp, "aliases.txt")
if (bundled.exists()) {
return if (isDirectoryWritable(bundled.parentFile)) {
// Program Files write protection isn't active (or this isn't an
// installed build) - edit the bundled copy in place, as intended.
bundled
} else {
println(
"[AliasRepository] ${bundled.absolutePath} is not writable " +
"(Program Files protection) - falling back to ${appDataFile.absolutePath}"
)
if (!appDataFile.exists()) {
appDataFile.parentFile?.mkdirs()
// Best-effort: carry over the bundled starter content. If even
// reading it fails (locked file, odd ACLs, etc.) fall back to the
// built-in default rather than leaving init() to crash the app.
try {
bundled.copyTo(appDataFile, overwrite = false)
} catch (e: Exception) {
println("[AliasRepository] Couldn't copy bundled aliases.txt (${e.message}) - writing default starter content instead")
appDataFile.writeText(defaultAliasesContent)
}
}
appDataFile
}
}
}
// Dev run (no installer resources dir set) or bundled file missing - seed a
// starter file directly under %APPDATA% so the feature still works from source.
if (!appDataFile.exists()) {
appDataFile.parentFile?.mkdirs()
appDataFile.writeText(defaultAliasesContent)
}
return appDataFile
}
private fun appDataRoot(): File =
System.getenv("APPDATA")?.let { File(it) }
?: File(System.getProperty("user.home"), "AppData/Roaming")
// File.canWrite() can lie (or simply be unavailable) on some Windows ACL setups, so
// this confirms writability the same way the file will actually be edited: a real
// write followed by cleanup.
private fun isDirectoryWritable(dir: File?): Boolean {
if (dir == null) return false
return try {
val probe = File(dir, ".wirc_write_test_${System.nanoTime()}.tmp")
probe.writeText("")
probe.delete()
true
} catch (e: Exception) {
false
}
}
}