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 = 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 { if (!file.exists()) return emptyMap() val result = mutableMapOf() 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 } } }