Initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.gradle/
|
||||
build/
|
||||
*.iml
|
||||
.idea/
|
||||
local.properties
|
||||
*.log
|
||||
kotlin-js-store/
|
||||
.kotlin/
|
||||
@@ -0,0 +1,49 @@
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
|
||||
plugins {
|
||||
kotlin("jvm") version "1.9.24"
|
||||
id("org.jetbrains.compose") version "1.6.11"
|
||||
}
|
||||
|
||||
group = "com.tpuahsiew.wirc"
|
||||
version = "0.1.4"
|
||||
|
||||
// No project-level repositories{} block here: settings.gradle.kts sets
|
||||
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
|
||||
// declares google()/mavenCentral()/the compose dev repo there, so a duplicate block in
|
||||
// this file would conflict with that policy and fail the build.
|
||||
|
||||
dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(compose.material3)
|
||||
// Icons.Default.{Person,Settings} used by the settings-gear/user-list toggle in
|
||||
// ChannelScreen live in material-icons-core, which materialIconsExtended pulls in -
|
||||
// compose.material3 alone doesn't include it.
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1")
|
||||
implementation("org.json:json:20240303")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "MainKt"
|
||||
nativeDistributions {
|
||||
// Bundles installerResources/ (aliases.txt starter file, etc.) into the
|
||||
// installed app directory, available at runtime via the
|
||||
// compose.application.resources.dir system property.
|
||||
appResourcesRootDir.set(project.file("installerResources"))
|
||||
targetFormats(TargetFormat.Msi, TargetFormat.Exe)
|
||||
packageName = "wIRC"
|
||||
packageVersion = "0.1.4"
|
||||
windows {
|
||||
menuGroup = "wIRC"
|
||||
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
|
||||
iconFile.set(project.file("icon.ico"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
+9
@@ -0,0 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# gradlew start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
@@ -0,0 +1,21 @@
|
||||
# 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 (full exe path, or a bare name that
|
||||
# relies on Windows' App Paths registry / PATH - not guaranteed
|
||||
# to work for every app)
|
||||
#
|
||||
# {arg} in the target is replaced with whatever text follows the command,
|
||||
# e.g. "/url https://example.com" opens that exact URL. A target with no
|
||||
# {arg} placeholder (like /blog below) always opens the same fixed target,
|
||||
# ignoring any trailing text.
|
||||
#
|
||||
# Edit this file and run /reload in any wIRC window to pick up changes
|
||||
# without restarting the app.
|
||||
|
||||
/c = path:C:\
|
||||
/blog = url:https://bloggin.space
|
||||
/url = url:{arg}
|
||||
@@ -0,0 +1,21 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
google()
|
||||
mavenCentral()
|
||||
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||||
}
|
||||
}
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "wIRC-desktop"
|
||||
@@ -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 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user