diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fdf79c17..9b874a94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Build and Release Desktop +name: Build and Release Latch on: push: @@ -15,8 +15,43 @@ permissions: actions: write jobs: + prepare: + name: Validate release version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.release.outputs.version }} + tag: ${{ steps.release.outputs.tag }} + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Match the tag to the project version + id: release + shell: bash + run: | + version="$(sed -n 's/^latchVersion=//p' gradle.properties)" + if [[ -z "$version" ]]; then + echo "latchVersion is missing from gradle.properties" >&2 + exit 1 + fi + + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + tag="${GITHUB_REF_NAME}" + else + tag="v${version}" + fi + + if [[ "$tag" != "v${version}" ]]; then + echo "Release tag $tag does not match latchVersion=$version" >&2 + exit 1 + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + build-linux: - name: Build Linux Package (.tar.gz) + name: Build Linux artifacts + needs: prepare runs-on: ubuntu-latest steps: - name: Checkout repo @@ -29,20 +64,43 @@ jobs: distribution: 'temurin' cache: gradle - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Build Linux .tar.gz - run: ./gradlew :desktop:packageReleaseTarGz - - - name: Upload Linux Artifact - uses: actions/upload-artifact@v7 + - name: Install native packaging tools + run: | + sudo apt-get update + sudo apt-get install --yes fakeroot rpm + + - name: Verify shared runtime and CLI + run: | + chmod +x gradlew + ./gradlew :core:desktopTest :cli:test :desktop:smoke + + - name: Build Desktop and CLI packages + run: | + ./gradlew \ + :desktop:packageReleaseTarGz \ + :cli:packageCliTarGz \ + :cli:packageCliDeb \ + :cli:packageCliRpm + + - name: Smoke-test bundled CLI + run: | + cli/build/cli-package/image/latch-cli/bin/latch-cli --version + test "$(cli/build/cli-package/image/latch-cli/bin/latch-cli --version)" = "latch-cli ${{ needs.prepare.outputs.version }}" + + - name: Upload Linux artifacts + uses: actions/upload-artifact@v4 with: - name: linux-tarball - path: desktop/build/distributions/*.tar.gz + name: linux-artifacts + if-no-files-found: error + path: | + desktop/build/distributions/*.tar.gz + cli/build/distributions/*.tar.gz + cli/build/distributions/*.deb + cli/build/distributions/*.rpm build-windows: - name: Build Windows Package (.msi) + name: Build Windows artifacts + needs: prepare runs-on: windows-latest steps: - name: Checkout repo @@ -55,48 +113,73 @@ jobs: distribution: 'temurin' cache: gradle - - name: Build Windows MSI - run: .\gradlew.bat :desktop:packageReleaseMsi + - name: Build Desktop MSI and portable CLI + run: .\gradlew.bat :desktop:packageReleaseMsi :cli:packageCliZip - - name: Upload Windows Artifact - uses: actions/upload-artifact@v7 + - name: Smoke-test bundled CLI + shell: pwsh + run: | + $launcher = "cli\build\cli-package\image\latch-cli\latch-cli.exe" + $actual = & $launcher --version + if ($LASTEXITCODE -ne 0 -or $actual -ne "latch-cli ${{ needs.prepare.outputs.version }}") { + throw "Unexpected CLI version output: $actual" + } + + - name: Upload Windows artifacts + uses: actions/upload-artifact@v4 with: - name: windows-msi - path: desktop/build/compose/binaries/main-release/msi/*.msi + name: windows-artifacts + if-no-files-found: error + path: | + desktop/build/compose/binaries/main-release/msi/*.msi + cli/build/distributions/*.zip release: - name: Create GitHub Release - needs: [build-linux, build-windows] + name: Create GitHub release + needs: [prepare, build-linux, build-windows] runs-on: ubuntu-latest steps: + - name: Checkout repo + uses: actions/checkout@v4 + - name: Download all artifacts uses: actions/download-artifact@v8 with: path: release-assets merge-multiple: true - - name: Create GitHub Release + - name: Generate package-manager manifests + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + packaging/generate-cli-package-metadata.sh \ + "$VERSION" \ + "release-assets/latch-cli-$VERSION-linux-x64.tar.gz" \ + "release-assets/latch-cli-$VERSION-windows-x64.zip" \ + package-metadata + (cd package-metadata && zip -qr "../release-assets/latch-cli-$VERSION-package-metadata.zip" .) + + - name: Generate checksums + working-directory: release-assets + run: sha256sum * > SHA256SUMS + + - name: Create GitHub release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.ref_name }} - name: ${{ github.ref_name }} + tag_name: ${{ needs.prepare.outputs.tag }} + name: ${{ needs.prepare.outputs.tag }} draft: false prerelease: false make_latest: true body: | - Update checker fix. - - What's New - - - Fixed: the app could show "update available" even when you already had the latest version + Latch Desktop and the standalone `latch-cli` now share one release version. - Download the compatible version for your machine from the assets below. - files: | - release-assets/* + CLI downloads include portable Linux and Windows bundles, Debian and RPM packages, checksums, and submission-ready AUR/winget metadata. + files: release-assets/* - name: Clear build artifacts storage uses: geekyeggo/delete-artifact@v6 with: name: | - linux-tarball - windows-msi + linux-artifacts + windows-artifacts diff --git a/README.md b/README.md index 7f8f2a46..6cc32ec9 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Latch is a Kotlin application developed by VinnovateIT that automates the login - Automatic detection of VIT hostel WiFi networks - Auto-login with securely stored credentials - Logging and display of network usage statistics +- Standalone CLI for Linux terminals and Windows PowerShell ## Prerequisites @@ -55,6 +56,50 @@ Before you start, make sure you have: To install manually instead, download `latch-1.3.8-linux-x64.tar.gz` from the [latest release](https://github.com/vinnovateit/latch/releases/latest) and extract it. +### Command-line app + +`latch-cli` is a standalone application with its own trimmed Java runtime; Java does not need to be installed separately. + +On Debian or Ubuntu, download the `.deb` from the [latest release](https://github.com/vinnovateit/latch/releases/latest), then run: + +```sh +sudo apt install ./latch-cli_1.3.8_amd64.deb +``` + +RPM-based distributions can install the release package with `sudo dnf install ./latch-cli-*.rpm`. Arch users can install the `latch-cli-bin` AUR package after its release metadata is submitted. The portable `latch-cli-1.3.8-linux-x64.tar.gz` works without package-manager installation. + +A hosted APT repository can be added later; the initial `.deb` is installed directly with `apt`. Flatpak is intentionally outside the CLI release scope because its sandbox and desktop-first distribution model do not fit a host-network command-line daemon. + +On Windows, install `VinnovateIT.LatchCLI` with winget after its manifest is accepted, or download and extract `latch-cli-1.3.8-windows-x64.zip`. The executable works directly from PowerShell: + +```powershell +.\latch-cli.exe --status +``` + +Run `latch-cli` with no arguments the first time. It prompts for your VIT credentials, starts the auto-login daemon in the background, and enables per-user startup at login. On later runs, `latch-cli` prints its help menu. + +Use `activate` and `deactivate` to control the background daemon on Linux or from PowerShell on Windows: + +```text +latch-cli activate +latch-cli deactivate +``` + +`activate` is idempotent and enables startup at login. `deactivate` stops a CLI-owned daemon and disables its login startup entry; it does not terminate a running desktop app. Common one-shot commands are: + +```text +latch-cli --set-credentials +latch-cli --status +latch-cli --login +latch-cli --logout +latch-cli --history +latch-cli --settings +latch-cli --settings set auto-login on +latch-cli --settings set allowed-ssids "VIT2.4G,VIT5G" +``` + +Desktop and CLI installations can coexist. They coordinate through an authenticated local connection so only one networking engine is active, and opening Desktop takes ownership from a running CLI daemon. + ### Android No pre-built APK is currently published for the Android app. To use it today, build it from source. See [Dev setup](#dev-setup) below. @@ -126,7 +171,7 @@ Optionally, it records network statistics for monitoring purposes. - Add support for multiple VIT campuses - Improve UI responsiveness -- CLI client ;) +- Publish the generated CLI manifests to additional package repositories See the [open issues](https://github.com/vinnovateit/latch/issues) for a full list of proposed features and known issues. diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 6dcbbbdc..a4af0bca 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -1,4 +1,16 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.gradle.api.tasks.bundling.Compression +import org.gradle.api.tasks.bundling.Tar +import org.gradle.api.tasks.bundling.Zip + +val latchVersion = providers.gradleProperty("latchVersion").get() +val hostIsWindows = System.getProperty("os.name").startsWith("Windows", ignoreCase = true) +val hostIsLinux = System.getProperty("os.name").contains("Linux", ignoreCase = true) +val hostArch = when (System.getProperty("os.arch").lowercase()) { + "amd64", "x86_64" -> "x64" + "aarch64", "arm64" -> "arm64" + else -> System.getProperty("os.arch").lowercase() +} plugins { alias(libs.plugins.kotlin.jvm) @@ -29,8 +41,140 @@ dependencies { implementation(project(":core")) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) + + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() } application { mainClass.set("com.vinnovateit.latch.cli.MainKt") + applicationName = "latch-cli" + applicationDefaultJvmArgs = listOf( + "-Xms8m", + "-Xmx64m", + "-XX:+UseSerialGC", + "-Dfile.encoding=UTF-8", + ) } + +val cliInstallDir = layout.buildDirectory.dir("install/latch-cli") +val cliPackageDir = layout.buildDirectory.dir("cli-package") +val cliImageDir = cliPackageDir.map { it.dir("image/latch-cli") } +val cliDistributionsDir = layout.buildDirectory.dir("distributions") +val linuxPackagingResources = layout.projectDirectory.dir("packaging/linux") + +val packageCliAppImage by tasks.registering(Exec::class) { + group = "distribution" + description = "Builds a standalone latch-cli app image with a bundled runtime." + dependsOn(tasks.installDist) + onlyIf { hostIsWindows || hostIsLinux } + outputs.dir(cliImageDir) + + doFirst { + delete(cliPackageDir.get().dir("image")) + } + + executable = File(System.getProperty("java.home"), "bin/jpackage").absolutePath + args( + "--type", "app-image", + "--dest", cliPackageDir.get().dir("image").asFile.absolutePath, + "--input", cliInstallDir.get().dir("lib").asFile.absolutePath, + "--main-jar", "cli.jar", + "--main-class", "com.vinnovateit.latch.cli.MainKt", + "--name", "latch-cli", + "--app-version", latchVersion, + "--vendor", "VinnovateIT", + "--description", "Automatic VIT Wi-Fi login from the terminal", + "--add-modules", "java.base,java.desktop,java.logging,java.management,java.naming,jdk.unsupported,java.instrument", + "--java-options", "-Xms8m", + "--java-options", "-Xmx64m", + "--java-options", "-XX:+UseSerialGC", + "--java-options", "-Dfile.encoding=UTF-8", + "--java-options", "-Djava.awt.headless=true", + ) + if (hostIsWindows) args("--win-console") +} + +tasks.register("packageCliTarGz") { + group = "distribution" + description = "Packages the Linux CLI app image as a portable tarball." + dependsOn(packageCliAppImage) + onlyIf { hostIsLinux } + archiveFileName.set("latch-cli-$latchVersion-linux-$hostArch.tar.gz") + destinationDirectory.set(cliDistributionsDir) + compression = Compression.GZIP + from(cliImageDir) { + into("latch-cli-$latchVersion-linux-$hostArch") + } +} + +tasks.register("packageCliZip") { + group = "distribution" + description = "Packages the Windows CLI app image as a portable ZIP." + dependsOn(packageCliAppImage) + onlyIf { hostIsWindows } + archiveFileName.set("latch-cli-$latchVersion-windows-$hostArch.zip") + destinationDirectory.set(cliDistributionsDir) + from(cliImageDir) { + into("latch-cli-$latchVersion-windows-$hostArch") + } +} + +fun registerLinuxPackageTask(taskName: String, packageType: String) = tasks.register(taskName) { + group = "distribution" + description = "Builds the standalone CLI .$packageType package with jpackage." + dependsOn(tasks.installDist) + onlyIf { hostIsLinux } + outputs.dir(cliDistributionsDir) + + doFirst { + cliDistributionsDir.get().asFile.mkdirs() + cliDistributionsDir.get().asFile.listFiles() + ?.filter { + it.name.startsWith("latch-cli") && + it.name.contains(latchVersion) && + it.extension == packageType + } + ?.forEach(File::delete) + } + + executable = File(System.getProperty("java.home"), "bin/jpackage").absolutePath + args( + "--type", packageType, + "--dest", cliDistributionsDir.get().asFile.absolutePath, + "--input", cliInstallDir.get().dir("lib").asFile.absolutePath, + "--main-jar", "cli.jar", + "--main-class", "com.vinnovateit.latch.cli.MainKt", + "--name", "latch-cli", + "--linux-package-name", "latch-cli", + "--app-version", latchVersion, + "--vendor", "VinnovateIT", + "--description", "Automatic VIT Wi-Fi login from the terminal", + "--add-modules", "java.base,java.desktop,java.logging,java.management,java.naming,jdk.unsupported,java.instrument", + "--java-options", "-Xms8m", + "--java-options", "-Xmx64m", + "--java-options", "-XX:+UseSerialGC", + "--java-options", "-Dfile.encoding=UTF-8", + "--java-options", "-Djava.awt.headless=true", + "--linux-app-category", "Network", + "--resource-dir", linuxPackagingResources.asFile.absolutePath, + ) + if (packageType == "deb") { + args( + "--linux-deb-maintainer", "VinnovateIT", + "--linux-package-deps", "network-manager", + ) + } + if (packageType == "rpm") { + args( + "--linux-rpm-license-type", "MIT", + "--linux-package-deps", "NetworkManager", + ) + } +} + +registerLinuxPackageTask("packageCliDeb", "deb") +registerLinuxPackageTask("packageCliRpm", "rpm") diff --git a/cli/packaging/linux/latch-cli.spec b/cli/packaging/linux/latch-cli.spec new file mode 100644 index 00000000..ca9902aa --- /dev/null +++ b/cli/packaging/linux/latch-cli.spec @@ -0,0 +1,94 @@ +Summary: APPLICATION_SUMMARY +Name: APPLICATION_PACKAGE +Version: APPLICATION_VERSION +Release: APPLICATION_RELEASE +License: APPLICATION_LICENSE_TYPE +Vendor: APPLICATION_VENDOR + +%if "xAPPLICATION_URL" != "x" +URL: APPLICATION_URL +%endif + +%if "xAPPLICATION_PREFIX" != "x" +Prefix: APPLICATION_PREFIX +%endif + +Provides: APPLICATION_PACKAGE + +%if "xAPPLICATION_GROUP" != "x" +Group: APPLICATION_GROUP +%endif + +Autoprov: 0 +Autoreq: 0 +%if "xPACKAGE_DEFAULT_DEPENDENCIES" != "x" || "xPACKAGE_CUSTOM_DEPENDENCIES" != "x" +Requires: PACKAGE_DEFAULT_DEPENDENCIES PACKAGE_CUSTOM_DEPENDENCIES +%endif + +%define __jar_repack %{nil} +%define _build_id_links none + +%define package_filelist %{_builddir}/%{name}.files +%define app_filelist %{_builddir}/%{name}.app.files +%define filesystem_filelist %{_builddir}/%{name}.filesystem.files +%define default_filesystem / /opt /usr /usr/bin /usr/lib /usr/local /usr/local/bin /usr/local/lib + +%description +APPLICATION_DESCRIPTION + +%global __os_install_post %{nil} + +%prep + +%build + +%install +rm -rf %{buildroot} +install -d -m 755 %{buildroot}APPLICATION_DIRECTORY +cp -r %{_sourcedir}APPLICATION_DIRECTORY/* %{buildroot}APPLICATION_DIRECTORY +install -d -m 755 %{buildroot}/usr/bin +ln -s APPLICATION_DIRECTORY/bin/latch-cli %{buildroot}/usr/bin/latch-cli +if [ "$(echo %{_sourcedir}/lib/systemd/system/*.service)" != '%{_sourcedir}/lib/systemd/system/*.service' ]; then + install -d -m 755 %{buildroot}/lib/systemd/system + cp %{_sourcedir}/lib/systemd/system/*.service %{buildroot}/lib/systemd/system +fi +%if "xAPPLICATION_LICENSE_FILE" != "x" + %define license_install_file %{_defaultlicensedir}/%{name}-%{version}/%{basename:APPLICATION_LICENSE_FILE} + install -d -m 755 "%{buildroot}%{dirname:%{license_install_file}}" + install -m 644 "APPLICATION_LICENSE_FILE" "%{buildroot}%{license_install_file}" +%endif +(cd %{buildroot} && find . -path ./lib/systemd -prune -o -type d -print) | sed -e 's/^\.//' -e '/^$/d' | sort > %{app_filelist} +{ rpm -ql filesystem || echo %{default_filesystem}; } | sort > %{filesystem_filelist} +comm -23 %{app_filelist} %{filesystem_filelist} > %{package_filelist} +sed -i -e 's/.*/%dir "&"/' %{package_filelist} +(cd %{buildroot} && find . -not -type d) | sed -e 's/^\.//' -e 's/.*/"&"/' >> %{package_filelist} +%if "xAPPLICATION_LICENSE_FILE" != "x" + sed -i -e 's|"%{license_install_file}"||' -e '/^$/d' %{package_filelist} +%endif + +%files -f %{package_filelist} +%if "xAPPLICATION_LICENSE_FILE" != "x" + %license "%{license_install_file}" +%endif + +%post +package_type=rpm +LAUNCHER_AS_SERVICE_SCRIPTS +DESKTOP_COMMANDS_INSTALL +LAUNCHER_AS_SERVICE_COMMANDS_INSTALL + +%pre +package_type=rpm +LAUNCHER_AS_SERVICE_SCRIPTS +if [ "$1" = 2 ]; then + true; LAUNCHER_AS_SERVICE_COMMANDS_UNINSTALL +fi + +%preun +package_type=rpm +DESKTOP_SCRIPTS +LAUNCHER_AS_SERVICE_SCRIPTS +DESKTOP_COMMANDS_UNINSTALL +LAUNCHER_AS_SERVICE_COMMANDS_UNINSTALL + +%clean diff --git a/cli/packaging/linux/postinst b/cli/packaging/linux/postinst new file mode 100644 index 00000000..96942e58 --- /dev/null +++ b/cli/packaging/linux/postinst @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +if [ "$1" = "configure" ]; then + ln -sfn /opt/latch-cli/bin/latch-cli /usr/bin/latch-cli +fi + +exit 0 diff --git a/cli/packaging/linux/prerm b/cli/packaging/linux/prerm new file mode 100644 index 00000000..798556f0 --- /dev/null +++ b/cli/packaging/linux/prerm @@ -0,0 +1,13 @@ +#!/bin/sh +set -e + +case "$1" in + remove|upgrade|deconfigure) + if [ -L /usr/bin/latch-cli ] && + [ "$(readlink /usr/bin/latch-cli)" = "/opt/latch-cli/bin/latch-cli" ]; then + rm /usr/bin/latch-cli + fi + ;; +esac + +exit 0 diff --git a/cli/src/main/kotlin/com/vinnovateit/latch/cli/CliBackend.kt b/cli/src/main/kotlin/com/vinnovateit/latch/cli/CliBackend.kt new file mode 100644 index 00000000..3872fa06 --- /dev/null +++ b/cli/src/main/kotlin/com/vinnovateit/latch/cli/CliBackend.kt @@ -0,0 +1,58 @@ +package com.vinnovateit.latch.cli + +data class CliStatus( + val owner: String, + val connection: String, + val ssid: String?, + val latched: Boolean, +) + +data class CliSession( + val start: Long, + val end: Long, + val rx: Long, + val tx: Long, + val maxRx: Long, + val maxTx: Long, +) + +data class CliSettings( + val autoLogin: Boolean, + val allowedSsids: Set, +) + +data class OperationResult( + val value: T? = null, + val error: String? = null, +) + +interface CliBackend : AutoCloseable { + suspend fun isSetup(): OperationResult + suspend fun status(): OperationResult + suspend fun login(): OperationResult + suspend fun logout(): OperationResult + suspend fun history(): OperationResult> + suspend fun settings(): OperationResult + suspend fun setAutoLogin(enabled: Boolean): OperationResult + suspend fun setAllowedSsids(values: Set): OperationResult + suspend fun setCredentials(userId: String, password: CharArray): OperationResult + suspend fun runDaemon(): OperationResult +} + +interface CliLifecycle { + suspend fun activate(): OperationResult + suspend fun deactivate(): OperationResult +} + +internal object UnavailableCliLifecycle : CliLifecycle { + override suspend fun activate() = OperationResult(error = "Background lifecycle is unavailable.") + override suspend fun deactivate() = OperationResult(error = "Background lifecycle is unavailable.") +} + +interface TerminalIO { + val interactive: Boolean + fun print(text: String) + fun println(text: String = "") + fun readLine(prompt: String): String? + fun readSecret(prompt: String): CharArray? +} diff --git a/cli/src/main/kotlin/com/vinnovateit/latch/cli/CliCommand.kt b/cli/src/main/kotlin/com/vinnovateit/latch/cli/CliCommand.kt new file mode 100644 index 00000000..041a4778 --- /dev/null +++ b/cli/src/main/kotlin/com/vinnovateit/latch/cli/CliCommand.kt @@ -0,0 +1,80 @@ +package com.vinnovateit.latch.cli + +sealed interface CliCommand { + data object Bootstrap : CliCommand + data object Activate : CliCommand + data object Deactivate : CliCommand + data object DaemonProcess : CliCommand + data object Status : CliCommand + data object Login : CliCommand + data object Logout : CliCommand + data object History : CliCommand + data object SetCredentials : CliCommand + data object GetSettings : CliCommand + data class SetAutoLogin(val enabled: Boolean) : CliCommand + data class SetAllowedSsids(val values: Set) : CliCommand + data object Help : CliCommand + data object Version : CliCommand +} + +sealed interface ParseResult { + data class Success(val command: CliCommand) : ParseResult + data class Failure(val message: String) : ParseResult +} + +private const val SETTINGS_USAGE = + "Usage: --settings [set auto-login | set allowed-ssids ]" + +fun parseCommand(args: Array): ParseResult { + if (args.isEmpty()) return ParseResult.Success(CliCommand.Bootstrap) + + if (args.first() == "--settings") return parseSettings(args) + + if (args.size != 1) { + return ParseResult.Failure("${args.first()} does not accept arguments") + } + + val command = when (args.first()) { + "activate" -> CliCommand.Activate + "deactivate" -> CliCommand.Deactivate + "--daemon-process" -> CliCommand.DaemonProcess + "--status" -> CliCommand.Status + "--login" -> CliCommand.Login + "--logout" -> CliCommand.Logout + "--history" -> CliCommand.History + "--set-credentials" -> CliCommand.SetCredentials + "--help" -> CliCommand.Help + "--version" -> CliCommand.Version + else -> return ParseResult.Failure("Unknown command: ${args.first()}") + } + return ParseResult.Success(command) +} + +private fun parseSettings(args: Array): ParseResult { + if (args.size == 1) return ParseResult.Success(CliCommand.GetSettings) + if (args.size != 4 || args[1] != "set") return ParseResult.Failure(SETTINGS_USAGE) + + return when (args[2]) { + "auto-login" -> when (args[3]) { + "on" -> ParseResult.Success(CliCommand.SetAutoLogin(enabled = true)) + "off" -> ParseResult.Success(CliCommand.SetAutoLogin(enabled = false)) + else -> ParseResult.Failure("auto-login must be on or off") + } + + "allowed-ssids" -> parseAllowedSsids(args[3]) + else -> ParseResult.Failure("Unknown settings key: ${args[2]}") + } +} + +private fun parseAllowedSsids(rawValue: String): ParseResult { + if (rawValue.isBlank()) { + return ParseResult.Failure("allowed-ssids must not be empty") + } + + val values = rawValue.split(',').map(String::trim) + if (values.any(String::isEmpty)) { + return ParseResult.Failure("allowed-ssids must not contain empty entries") + } + + return ParseResult.Success(CliCommand.SetAllowedSsids(values.toCollection(linkedSetOf()))) +} diff --git a/cli/src/main/kotlin/com/vinnovateit/latch/cli/CliOutput.kt b/cli/src/main/kotlin/com/vinnovateit/latch/cli/CliOutput.kt new file mode 100644 index 00000000..92539e2b --- /dev/null +++ b/cli/src/main/kotlin/com/vinnovateit/latch/cli/CliOutput.kt @@ -0,0 +1,57 @@ +package com.vinnovateit.latch.cli + +import java.time.Instant + +internal object CliOutput { + val help: String = + """ + Usage: latch-cli [command] + + (no command) Onboard this machine, or show help when configured. + activate Start Latch in the background and at login. + deactivate Stop the CLI daemon and disable login startup. + --status Show the current connection status. + --login Attempt one login. + --logout Log out once. + --history List recorded sessions, newest first. + --set-credentials Prompt for and save credentials. + --settings Show CLI settings. + --settings set auto-login Enable or disable automatic login. + --settings set allowed-ssids Replace the allowed SSID list. + --help Show this help. + --version Show the installed version. + """.trimIndent() + + fun status(value: CliStatus): String = buildString { + appendLine("owner: ${value.owner}") + appendLine("connection: ${value.connection}") + appendLine("ssid: ${value.ssid ?: "none"}") + appendLine("latched: ${if (value.latched) "yes" else "no"}") + } + + fun history(values: List): String { + if (values.isEmpty()) return "No sessions.\n" + + return buildString { + appendLine("start\tend\trx-bytes\ttx-bytes\tmax-rx-bps\tmax-tx-bps") + values.sortedByDescending(CliSession::start).forEach { session -> + append(Instant.ofEpochMilli(session.start)) + append('\t') + append(Instant.ofEpochMilli(session.end)) + append('\t') + append(session.rx) + append('\t') + append(session.tx) + append('\t') + append(session.maxRx) + append('\t') + appendLine(session.maxTx) + } + } + } + + fun settings(value: CliSettings): String = buildString { + appendLine("auto-login: ${if (value.autoLogin) "on" else "off"}") + appendLine("allowed-ssids: ${value.allowedSsids.sorted().joinToString(",")}") + } +} diff --git a/cli/src/main/kotlin/com/vinnovateit/latch/cli/CliRunner.kt b/cli/src/main/kotlin/com/vinnovateit/latch/cli/CliRunner.kt new file mode 100644 index 00000000..bec7175e --- /dev/null +++ b/cli/src/main/kotlin/com/vinnovateit/latch/cli/CliRunner.kt @@ -0,0 +1,169 @@ +package com.vinnovateit.latch.cli + +import com.vinnovateit.latch.core.LatchCore +import kotlinx.coroutines.CancellationException + +const val EXIT_SUCCESS = 0 +const val EXIT_OPERATIONAL_ERROR = 1 +const val EXIT_USAGE_ERROR = 2 + +suspend fun runCli( + args: Array, + terminal: TerminalIO, + version: String = LatchCore.VERSION, + lifecycle: CliLifecycle = UnavailableCliLifecycle, + backendFactory: suspend (CliCommand) -> CliBackend, +): Int = when (val parsed = parseCommand(args)) { + is ParseResult.Success -> CliRunner( + terminal, + { backendFactory(parsed.command) }, + version, + lifecycle = lifecycle, + ).run(parsed.command) + is ParseResult.Failure -> { + terminal.println("error: ${parsed.message}") + terminal.println(CliOutput.help) + EXIT_USAGE_ERROR + } +} + +class CliRunner( + private val terminal: TerminalIO, + private val backendFactory: suspend () -> CliBackend, + private val version: String = LatchCore.VERSION, + private val splash: suspend (TerminalIO) -> Unit = { output -> + showSplash(output, detectSplashCapabilities(output)) + }, + private val lifecycle: CliLifecycle = UnavailableCliLifecycle, +) { + suspend fun run(command: CliCommand): Int { + when (command) { + CliCommand.Help -> { + terminal.println(CliOutput.help) + return EXIT_SUCCESS + } + + CliCommand.Version -> { + terminal.println("latch-cli $version") + return EXIT_SUCCESS + } + + CliCommand.Activate -> return activate() + CliCommand.Deactivate -> return deactivate() + CliCommand.Bootstrap -> return bootstrap() + + else -> Unit + } + + val backend = try { + backendFactory() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + return fail(error.message ?: "Unable to initialize Latch.") + } + + return try { + runWithBackend(command, backend) + } finally { + backend.close() + } + } + + private suspend fun runWithBackend(command: CliCommand, backend: CliBackend): Int = when (command) { + CliCommand.Bootstrap -> error("Handled before backend creation") + CliCommand.DaemonProcess -> report(backend.runDaemon()) + CliCommand.Activate, CliCommand.Deactivate -> error("Handled before backend creation") + CliCommand.Status -> report(backend.status(), CliOutput::status) + CliCommand.Login -> report(backend.login(), successMessage = "Login completed.") + CliCommand.Logout -> report(backend.logout(), successMessage = "Logout completed.") + CliCommand.History -> report(backend.history(), CliOutput::history) + CliCommand.GetSettings -> report(backend.settings(), CliOutput::settings) + is CliCommand.SetAutoLogin -> report( + backend.setAutoLogin(command.enabled), + successMessage = "auto-login: ${if (command.enabled) "on" else "off"}", + ) + + is CliCommand.SetAllowedSsids -> report( + backend.setAllowedSsids(command.values), + successMessage = "allowed-ssids: ${command.values.sorted().joinToString(",")}", + ) + + CliCommand.SetCredentials -> setCredentials(backend) + CliCommand.Help, CliCommand.Version -> error("Handled before backend creation") + } + + private suspend fun bootstrap(): Int { + splash(terminal) + val backend = try { + backendFactory() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + return fail(error.message ?: "Unable to initialize Latch.") + } + + val shouldActivate = try { + val setup = backend.isSetup() + setup.error?.let { return fail(it) } + val configured = setup.value ?: return fail("The operation returned no setup status.") + if (configured) { + terminal.println(CliOutput.help) + return EXIT_SUCCESS + } + + terminal.println("Welcome to Latch.") + val credentials = promptForCredentials(terminal).getOrElse { + return fail(it.message ?: "Invalid credentials.") + } + try { + val saved = backend.setCredentials(credentials.userId, credentials.password) + saved.error?.let { return fail(it) } + } finally { + credentials.password.fill('\u0000') + } + true + } finally { + backend.close() + } + + return if (shouldActivate) activate() else EXIT_SUCCESS + } + + private suspend fun activate(): Int = report( + lifecycle.activate(), + successMessage = "Latch is running in the background and will start when you log in.", + ) + + private suspend fun deactivate(): Int = report( + lifecycle.deactivate(), + successMessage = "Latch background daemon stopped and login startup disabled.", + ) + + private suspend fun setCredentials(backend: CliBackend): Int { + val credentials = promptForCredentials(terminal).getOrElse { return fail(it.message ?: "Invalid credentials.") } + return try { + report(backend.setCredentials(credentials.userId, credentials.password), successMessage = "Credentials saved.") + } finally { + credentials.password.fill('\u0000') + } + } + + private fun report(result: OperationResult, successMessage: String? = null): Int { + result.error?.let { return fail(it) } + successMessage?.let(terminal::println) + return EXIT_SUCCESS + } + + private fun report(result: OperationResult, render: (T) -> String): Int { + result.error?.let { return fail(it) } + val value = result.value ?: return fail("The operation returned no result.") + terminal.print(render(value)) + return EXIT_SUCCESS + } + + private fun fail(message: String): Int { + terminal.println("error: $message") + return EXIT_OPERATIONAL_ERROR + } +} diff --git a/cli/src/main/kotlin/com/vinnovateit/latch/cli/CoordinatedCliBackend.kt b/cli/src/main/kotlin/com/vinnovateit/latch/cli/CoordinatedCliBackend.kt new file mode 100644 index 00000000..c0e2dc5f --- /dev/null +++ b/cli/src/main/kotlin/com/vinnovateit/latch/cli/CoordinatedCliBackend.kt @@ -0,0 +1,151 @@ +package com.vinnovateit.latch.cli + +import com.vinnovateit.latch.core.engine.LatchCommand +import com.vinnovateit.latch.core.runtime.AcquireResult +import com.vinnovateit.latch.core.runtime.DesktopEngineRuntime +import com.vinnovateit.latch.core.runtime.INSTANCE_PROTOCOL_VERSION +import com.vinnovateit.latch.core.runtime.InstanceCoordinator +import com.vinnovateit.latch.core.runtime.InstanceRequest +import com.vinnovateit.latch.core.runtime.InstanceResponse +import com.vinnovateit.latch.core.runtime.OwnerKind +import com.vinnovateit.latch.core.runtime.RuntimeCommand +import com.vinnovateit.latch.core.runtime.RuntimeCommandService +import com.vinnovateit.latch.core.settings.SettingsManager +import com.vinnovateit.latch.desktop.AppPaths +import java.io.File +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking + +internal suspend fun createCoordinatedCliBackend( + command: CliCommand, + terminal: TerminalIO, + dataDir: File = AppPaths.dataDir, +): CliBackend { + val ownerKind = if (command == CliCommand.DaemonProcess) OwnerKind.CLI_DAEMON else OwnerKind.CLI_ONESHOT + val serviceReady = CompletableDeferred() + val acquired = InstanceCoordinator.tryAcquire(dataDir, ownerKind) { request -> + serviceReady.await().execute(request) + } + + return when (acquired) { + is AcquireResult.Existing -> { + val remote = RemoteCliBackend(acquired.client) + if (command == CliCommand.DaemonProcess) ExistingDaemonBackend(remote, terminal, acquired.metadata.ownerKind) + else remote + } + is AcquireResult.Failure -> error(acquired.message) + is AcquireResult.Owner -> createOwnerBackend(ownerKind, terminal, acquired.coordinator, serviceReady) + } +} + +private suspend fun createOwnerBackend( + ownerKind: OwnerKind, + terminal: TerminalIO, + coordinator: InstanceCoordinator, + serviceReady: CompletableDeferred, +): CliBackend { + return try { + val runtime = DesktopEngineRuntime.create( + ConsoleNotifier(terminal), + echoLogsToStdout = ownerKind == OwnerKind.CLI_DAEMON, + ) + val stopSignal = CompletableDeferred() + val service = RuntimeCommandService( + ownerKind = ownerKind, + runtime = runtime, + onTakeOver = { + if (ownerKind == OwnerKind.CLI_DAEMON) { + stopSignal.complete(Unit) + true + } else { + false + } + }, + onDeactivate = { + if (ownerKind == OwnerKind.CLI_DAEMON) { + stopSignal.complete(Unit) + true + } else { + false + } + }, + ) + serviceReady.complete(service) + runtime.start() + // Mirror LatchApp.start(): probe once on startup so a network that is + // already authenticated is recognised without waiting for the next + // Wi-Fi event, which otherwise leaves `--status` reporting "latched: no" + // for a freshly activated daemon. + if (ownerKind == OwnerKind.CLI_DAEMON) { + runtime.engine.submit( + if (SettingsManager.autoLogin.value) LatchCommand.CheckAndLogin + else LatchCommand.SilentCheck, + ) + } + OwnedCliBackend(ownerKind, runtime, coordinator, service, stopSignal) + } catch (error: Exception) { + serviceReady.completeExceptionally(error) + coordinator.close() + throw error + } +} + +private class OwnedCliBackend( + private val ownerKind: OwnerKind, + private val runtime: DesktopEngineRuntime, + private val coordinator: InstanceCoordinator, + service: RuntimeCommandService, + private val stopSignal: CompletableDeferred, +) : CliBackend by ProtocolCliBackend({ command, arguments -> + service.execute( + InstanceRequest( + version = INSTANCE_PROTOCOL_VERSION, + token = "local-owner", + requestId = UUID.randomUUID().toString(), + command = command, + arguments = arguments, + ), + ) +}) { + private val closed = AtomicBoolean(false) + private val shutdownHook = Thread( + { close(fromShutdownHook = true) }, + "LatchCliShutdown", + ).also(Runtime.getRuntime()::addShutdownHook) + + override suspend fun runDaemon(): OperationResult { + check(ownerKind == OwnerKind.CLI_DAEMON) + stopSignal.await() + return OperationResult(Unit) + } + + override fun close() = close(fromShutdownHook = false) + + private fun close(fromShutdownHook: Boolean) { + if (!closed.compareAndSet(false, true)) return + if (!fromShutdownHook) runCatching { Runtime.getRuntime().removeShutdownHook(shutdownHook) } + runBlocking { runtime.close() } + coordinator.close() + } +} + +private class ExistingDaemonBackend( + delegate: CliBackend, + private val terminal: TerminalIO, + private val activeOwner: OwnerKind, +) : CliBackend by delegate { + override suspend fun runDaemon(): OperationResult { + terminal.println("Latch is already running as ${activeOwner.name.lowercase().replace('_', '-')}.") + return OperationResult(Unit) + } +} + +private class ConsoleNotifier(private val terminal: TerminalIO) : com.vinnovateit.latch.core.platform.UserNotifier { + override fun showOngoing(title: String, text: String) = Unit + override fun notifyTransient(title: String, text: String, isError: Boolean) { + terminal.println("[$title] $text") + } + override fun hideOngoing() = Unit +} diff --git a/cli/src/main/kotlin/com/vinnovateit/latch/cli/CredentialPrompt.kt b/cli/src/main/kotlin/com/vinnovateit/latch/cli/CredentialPrompt.kt new file mode 100644 index 00000000..04d42776 --- /dev/null +++ b/cli/src/main/kotlin/com/vinnovateit/latch/cli/CredentialPrompt.kt @@ -0,0 +1,19 @@ +package com.vinnovateit.latch.cli + +internal data class PromptedCredentials( + val userId: String, + val password: CharArray, +) + +internal fun promptForCredentials(terminal: TerminalIO): Result { + val userId = terminal.readLine("User ID: ")?.trim().orEmpty() + if (userId.isEmpty()) return Result.failure(IllegalArgumentException("A user ID is required.")) + + val password = terminal.readSecret("Password: ") + if (password == null || password.isEmpty()) { + password?.fill('\u0000') + return Result.failure(IllegalArgumentException("A password is required.")) + } + + return Result.success(PromptedCredentials(userId, password)) +} diff --git a/cli/src/main/kotlin/com/vinnovateit/latch/cli/LoginStartup.kt b/cli/src/main/kotlin/com/vinnovateit/latch/cli/LoginStartup.kt new file mode 100644 index 00000000..684c1a7b --- /dev/null +++ b/cli/src/main/kotlin/com/vinnovateit/latch/cli/LoginStartup.kt @@ -0,0 +1,84 @@ +package com.vinnovateit.latch.cli + +import java.io.File + +internal interface CommandExecutor { + fun execute(command: List): OperationResult +} + +internal class ProcessCommandExecutor : CommandExecutor { + override fun execute(command: List): OperationResult = runCatching { + val process = ProcessBuilder(command) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + val exitCode = process.waitFor() + if (exitCode != 0) { + return OperationResult(error = "${command.first()} exited with code $exitCode.") + } + OperationResult(Unit) + }.getOrElse { OperationResult(error = it.message ?: "Unable to update login startup.") } +} + +internal class LinuxLoginStartup(configDirectory: File) : LoginStartup { + private val entry = configDirectory.resolve("autostart/latch-cli.desktop") + + override fun enable(command: List): OperationResult = runCatching { + require(command.isNotEmpty()) { "Daemon command is empty." } + entry.parentFile?.mkdirs() + entry.writeText( + """ + [Desktop Entry] + Type=Application + Name=Latch CLI + Comment=Automatic VIT Wi-Fi login + Exec=${command.joinToString(" ", transform = ::desktopQuote)} + Terminal=false + X-GNOME-Autostart-enabled=true + Categories=Network; + """.trimIndent() + "\n", + ) + OperationResult(Unit) + }.getOrElse { OperationResult(error = it.message ?: "Unable to enable login startup.") } + + override fun disable(): OperationResult = runCatching { + if (entry.exists() && !entry.delete()) error("Unable to remove ${entry.absolutePath}.") + OperationResult(Unit) + }.getOrElse { OperationResult(error = it.message ?: "Unable to disable login startup.") } +} + +internal class WindowsLoginStartup( + private val executor: CommandExecutor = ProcessCommandExecutor(), +) : LoginStartup { + override fun enable(command: List): OperationResult { + if (command.isEmpty()) return OperationResult(error = "Daemon command is empty.") + return executor.execute( + listOf( + "reg.exe", "ADD", RUN_KEY, "/v", RUN_VALUE, "/t", "REG_SZ", "/d", + command.joinToString(" ", transform = ::windowsQuote), "/f", + ), + ) + } + + override fun disable(): OperationResult = executor.execute( + listOf("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", REMOVE_RUN_VALUE_SCRIPT), + ) + + private companion object { + const val RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" + const val RUN_VALUE = "Latch CLI" + const val REMOVE_RUN_VALUE_SCRIPT = + "\$ErrorActionPreference = 'Stop'; " + + "\$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey(" + + "'Software\\Microsoft\\Windows\\CurrentVersion\\Run', \$true); " + + "if (\$null -ne \$key) { try { " + + "if (\$null -ne \$key.GetValue('Latch CLI', \$null, " + + "[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)) { " + + "\$key.DeleteValue('Latch CLI', \$false) } } finally { \$key.Dispose() } }" + } +} + +private fun desktopQuote(value: String): String = + "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"").replace("$", "\\$") + "\"" + +private fun windowsQuote(value: String): String = "\"${value.replace("\"", "\\\"")}\"" diff --git a/cli/src/main/kotlin/com/vinnovateit/latch/cli/Main.kt b/cli/src/main/kotlin/com/vinnovateit/latch/cli/Main.kt index 35a38576..17efd016 100644 --- a/cli/src/main/kotlin/com/vinnovateit/latch/cli/Main.kt +++ b/cli/src/main/kotlin/com/vinnovateit/latch/cli/Main.kt @@ -1,102 +1,34 @@ package com.vinnovateit.latch.cli -import com.vinnovateit.latch.core.data.buildDatabase -import com.vinnovateit.latch.core.domain.SessionRepository -import com.vinnovateit.latch.core.engine.LatchCommand -import com.vinnovateit.latch.core.engine.LatchEngine -import com.vinnovateit.latch.core.platform.Platform -import com.vinnovateit.latch.core.platform.UserNotifier -import com.vinnovateit.latch.core.settings.SettingsManager -import com.vinnovateit.latch.core.stats.ThroughputMonitor -import com.vinnovateit.latch.core.wifi.ConnectionStatus -import com.vinnovateit.latch.desktop.platform.DesktopPlatformServices import kotlinx.coroutines.runBlocking - -/** How long a one-shot command waits for the engine to reach Success/Failed. */ -private const val COMMAND_TIMEOUT_MS = 20_000L - -/** Prints to stdout instead of a tray -- there is no tray in a terminal. */ -private class ConsoleNotifier : UserNotifier { - override fun showOngoing(title: String, text: String) { - // High-frequency (every 2s); the tray tooltip's job. Not worth a log - // line per tick in a terminal. - } - - override fun notifyTransient(title: String, text: String, isError: Boolean) { - println("[$title] $text") - } - - override fun hideOngoing() { - // No tray icon to clear in a terminal. +import kotlin.system.exitProcess + +fun main(args: Array) { + val terminal = SystemTerminal + val lifecycle = createSystemCliLifecycle() + val exitCode = runBlocking { + runCli(args, terminal, lifecycle = lifecycle) { command -> + createCoordinatedCliBackend(command, terminal) + } } + if (exitCode != EXIT_SUCCESS) exitProcess(exitCode) } -private fun usage(): Nothing { - println( - """ - Usage: latch [command] +internal object SystemTerminal : TerminalIO { + private val console get() = System.console() - (no command) Run in the foreground: connect, monitor, and print state changes. - --status Print the current connection status and exit. - --login Attempt to log in once and exit. - --logout Log out and exit. - """.trimIndent() - ) - kotlin.system.exitProcess(1) -} - -fun main(args: Array) = runBlocking { - val platform = DesktopPlatformServices(echoLogsToStdout = true, notifier = ConsoleNotifier()) - Platform.install(platform) - SettingsManager.initialize(platform.settingsStore) + override val interactive: Boolean + get() = console != null - val database = buildDatabase() - val throughput = ThroughputMonitor(platform.counters) - val sessions = SessionRepository(database.statsDao(), throughput) - sessions.initialize() + override fun print(text: String) = kotlin.io.print(text) - val engine = LatchEngine(platform, sessions) - // Idempotent: also starts the command-processing loop that submit() feeds, - // needed even for the one-shot commands below, not just the daemon case. - engine.start() - - when (args.firstOrNull()) { - null -> { - var wasLatched = false - engine.isLatched.collect { latched -> - if (latched != wasLatched) { - println(if (latched) "Latched onto Wi-Fi." else "No longer latched.") - wasLatched = latched - } - } - } + override fun println(text: String) = kotlin.io.println(text) - "--status" -> awaitResult(engine, LatchCommand.SilentCheck) - "--login" -> awaitResult(engine, LatchCommand.CheckAndLogin) - "--logout" -> awaitResult(engine, LatchCommand.Logout) - else -> usage() + override fun readLine(prompt: String): String? { + console?.let { return it.readLine("%s", prompt) } + print(prompt) + return readlnOrNull() } - database.close() -} - -/** - * Waits for [command] to actually finish processing (submitAndAwait suspends - * on the engine's own completion signal, not a status-flow guess that could - * match a stale value left over from before the command even ran), then reads - * status.value directly. Safe here because this process only ever runs one - * command: status starts at Idle and nothing else touches it first, so if the - * command timed out (submitAndAwait -> false) or returned early without - * posting anything (e.g. SilentCheck/CheckAndLogin with no Wi-Fi to act on), - * status.value is still Idle and correctly falls through to "no result". - */ -private suspend fun awaitResult(engine: LatchEngine, command: LatchCommand) { - val completed = engine.submitAndAwait(command, COMMAND_TIMEOUT_MS) - val result = engine.status.value - val message = if (completed && (result is ConnectionStatus.Success || result is ConnectionStatus.Failed)) { - "status: $result" - } else { - "no result after ${COMMAND_TIMEOUT_MS / 1000}s (is Wi-Fi connected?)" - } - println(message) + override fun readSecret(prompt: String): CharArray? = console?.readPassword("%s", prompt) } diff --git a/cli/src/main/kotlin/com/vinnovateit/latch/cli/PersistentCliLifecycle.kt b/cli/src/main/kotlin/com/vinnovateit/latch/cli/PersistentCliLifecycle.kt new file mode 100644 index 00000000..a227ba8f --- /dev/null +++ b/cli/src/main/kotlin/com/vinnovateit/latch/cli/PersistentCliLifecycle.kt @@ -0,0 +1,69 @@ +package com.vinnovateit.latch.cli + +import com.vinnovateit.latch.core.runtime.OwnerKind +import kotlinx.coroutines.delay + +internal interface LoginStartup { + fun enable(command: List): OperationResult + fun disable(): OperationResult +} + +internal interface DaemonLauncher { + fun launch(command: List): OperationResult +} + +internal interface RuntimeOwnerControl { + suspend fun activeOwner(): OperationResult + suspend fun stopCliDaemon(): OperationResult +} + +internal class PersistentCliLifecycle( + private val startup: LoginStartup, + private val launcher: DaemonLauncher, + private val owners: RuntimeOwnerControl, + private val daemonCommand: List, + private val retryDelayMillis: Long = 100, + private val attempts: Int = 300, +) : CliLifecycle { + override suspend fun activate(): OperationResult { + startup.enable(daemonCommand).error?.let { return OperationResult(error = it) } + var launchedForCurrentVacancy = false + repeat(attempts) { + val owner = owners.activeOwner() + if (owner.error == null) { + when (owner.value) { + OwnerKind.DESKTOP, OwnerKind.CLI_DAEMON -> return OperationResult(Unit) + OwnerKind.CLI_ONESHOT -> launchedForCurrentVacancy = false + null -> if (!launchedForCurrentVacancy) { + launcher.launch(daemonCommand).error?.let { + startup.disable() + return OperationResult(error = it) + } + launchedForCurrentVacancy = true + } + } + } + if (retryDelayMillis > 0) delay(retryDelayMillis) + } + + startup.disable() + return OperationResult(error = "Latch background daemon did not become ready.") + } + + override suspend fun deactivate(): OperationResult { + startup.disable().error?.let { return OperationResult(error = it) } + + val current = owners.activeOwner() + current.error?.let { return OperationResult(error = it) } + if (current.value != OwnerKind.CLI_DAEMON) return OperationResult(Unit) + + owners.stopCliDaemon().error?.let { return OperationResult(error = it) } + repeat(attempts) { + if (retryDelayMillis > 0) delay(retryDelayMillis) + val owner = owners.activeOwner() + if (owner.error != null) return@repeat + if (owner.value != OwnerKind.CLI_DAEMON) return OperationResult(Unit) + } + return OperationResult(error = "Latch background daemon did not stop.") + } +} diff --git a/cli/src/main/kotlin/com/vinnovateit/latch/cli/RemoteCliBackend.kt b/cli/src/main/kotlin/com/vinnovateit/latch/cli/RemoteCliBackend.kt new file mode 100644 index 00000000..6d3de302 --- /dev/null +++ b/cli/src/main/kotlin/com/vinnovateit/latch/cli/RemoteCliBackend.kt @@ -0,0 +1,90 @@ +package com.vinnovateit.latch.cli + +import com.vinnovateit.latch.core.runtime.InstanceClient +import com.vinnovateit.latch.core.runtime.InstanceResponse +import com.vinnovateit.latch.core.runtime.RuntimeCommand +import com.vinnovateit.latch.core.runtime.RuntimeSessionRecord +import kotlinx.serialization.json.Json + +class RemoteCliBackend(client: InstanceClient) : CliBackend by ProtocolCliBackend(client::send) + +internal class ProtocolCliBackend( + private val send: suspend (RuntimeCommand, Map) -> InstanceResponse, +) : CliBackend { + override suspend fun isSetup(): OperationResult { + val response = send(RuntimeCommand.SETUP_STATUS, emptyMap()) + response.errorOrNull()?.let { return OperationResult(error = it) } + val configured = response.data["configured"]?.toBooleanStrictOrNull() + ?: return OperationResult(error = "The owner returned invalid setup status.") + return OperationResult(configured) + } + + override suspend fun status(): OperationResult { + val response = send(RuntimeCommand.STATUS, emptyMap()) + response.errorOrNull()?.let { return OperationResult(error = it) } + return OperationResult( + CliStatus( + owner = response.data["owner"].orEmpty(), + connection = response.data["connection"].orEmpty(), + ssid = response.data["ssid"]?.takeIf(String::isNotEmpty), + latched = response.data["latched"]?.toBooleanStrictOrNull() ?: false, + ), + ) + } + + override suspend fun login(): OperationResult = + send(RuntimeCommand.LOGIN, emptyMap()).toUnitResult() + + override suspend fun logout(): OperationResult = + send(RuntimeCommand.LOGOUT, emptyMap()).toUnitResult() + + override suspend fun history(): OperationResult> { + val response = send(RuntimeCommand.HISTORY, emptyMap()) + response.errorOrNull()?.let { return OperationResult(error = it) } + val records = runCatching { + JSON.decodeFromString>(response.data.getValue("sessions")) + }.getOrElse { return OperationResult(error = "The owner returned invalid session history.") } + return OperationResult( + records.map { CliSession(it.start, it.end, it.rx, it.tx, it.maxRx, it.maxTx) }, + ) + } + + override suspend fun settings(): OperationResult { + val response = send(RuntimeCommand.GET_SETTINGS, emptyMap()) + response.errorOrNull()?.let { return OperationResult(error = it) } + val autoLogin = response.data["autoLogin"]?.toBooleanStrictOrNull() + ?: return OperationResult(error = "The owner returned invalid settings.") + val ssids = runCatching { + JSON.decodeFromString>(response.data.getValue("allowedSsids")).toSet() + }.getOrElse { return OperationResult(error = "The owner returned invalid settings.") } + return OperationResult(CliSettings(autoLogin, ssids)) + } + + override suspend fun setAutoLogin(enabled: Boolean): OperationResult = send( + RuntimeCommand.SET_SETTING, + mapOf("key" to "auto-login", "value" to if (enabled) "on" else "off"), + ).toUnitResult() + + override suspend fun setAllowedSsids(values: Set): OperationResult = send( + RuntimeCommand.SET_SETTING, + mapOf("key" to "allowed-ssids", "value" to values.joinToString(",")), + ).toUnitResult() + + override suspend fun setCredentials(userId: String, password: CharArray): OperationResult = send( + RuntimeCommand.SET_CREDENTIALS, + mapOf("userId" to userId, "password" to password.concatToString()), + ).toUnitResult() + + override suspend fun runDaemon(): OperationResult = + OperationResult(error = "Latch is already running.") + + override fun close() = Unit +} + +private fun InstanceResponse.toUnitResult(): OperationResult = + errorOrNull()?.let { OperationResult(error = it) } ?: OperationResult(Unit) + +private fun InstanceResponse.errorOrNull(): String? = + if (ok) null else message.ifBlank { code } + +private val JSON = Json { ignoreUnknownKeys = false } diff --git a/cli/src/main/kotlin/com/vinnovateit/latch/cli/Splash.kt b/cli/src/main/kotlin/com/vinnovateit/latch/cli/Splash.kt new file mode 100644 index 00000000..73a27161 --- /dev/null +++ b/cli/src/main/kotlin/com/vinnovateit/latch/cli/Splash.kt @@ -0,0 +1,195 @@ +package com.vinnovateit.latch.cli + +import java.awt.geom.Area +import java.awt.geom.Path2D +import kotlin.math.PI +import kotlin.math.hypot +import kotlin.math.sin +import kotlinx.coroutines.delay + +private const val SPLASH_WIDTH = 56 +private const val SPLASH_HEIGHT = 20 +private const val DOT_WIDTH = SPLASH_WIDTH * 2 +private const val DOT_HEIGHT = SPLASH_HEIGHT * 4 +private const val FRAME_COUNT = 10 +private const val BRAND_RED_TRUE_COLOR = "\u001b[38;2;192;18;33m" +private const val BRAND_RED_BASIC = "\u001b[31m" +private const val BACKGROUND_COLOR = "\u001b[2;37m" +private const val RESET = "\u001b[0m" + +data class SplashCapabilities( + val interactive: Boolean, + val ansi: Boolean, + val trueColor: Boolean, + val noColor: Boolean, +) + +fun detectSplashCapabilities( + terminal: TerminalIO, + environment: Map = System.getenv(), + osName: String = System.getProperty("os.name", ""), +): SplashCapabilities { + val interactive = terminal.interactive + val noColor = environment.containsKey("NO_COLOR") + val term = environment["TERM"].orEmpty() + val windows = osName.startsWith("Windows", ignoreCase = true) + val windowsAnsi = environment.containsKey("WT_SESSION") || + environment.containsKey("ANSICON") || + environment["ConEmuANSI"].equals("ON", ignoreCase = true) || + term.contains("xterm", ignoreCase = true) + val ansi = interactive && !noColor && !term.equals("dumb", ignoreCase = true) && (!windows || windowsAnsi) + val colorTerm = environment["COLORTERM"].orEmpty() + val trueColor = ansi && ( + colorTerm.contains("truecolor", ignoreCase = true) || + colorTerm.contains("24bit", ignoreCase = true) || + environment.containsKey("WT_SESSION") + ) + return SplashCapabilities(interactive, ansi, trueColor, noColor) +} + +class SplashRenderer(private val seed: Long = 0L) { + private val logo = Area().apply { + LOGO_PATHS.forEach { add(Area(parsePath(it))) } + } + + fun frame(progress: Double, capabilities: SplashCapabilities): List { + val amount = progress.coerceIn(0.0, 1.0) + return List(SPLASH_HEIGHT) { row -> renderRow(row, amount, capabilities) } + } + + private fun renderRow(row: Int, progress: Double, capabilities: SplashCapabilities): String = buildString { + var activeColor = "" + repeat(SPLASH_WIDTH) { column -> + val cell = brailleCell(column, row, progress) + if (capabilities.ansi && !capabilities.noColor && cell.bits != 0) { + val color = when { + cell.logo && capabilities.trueColor -> BRAND_RED_TRUE_COLOR + cell.logo -> BRAND_RED_BASIC + else -> BACKGROUND_COLOR + } + if (color != activeColor) { + append(color) + activeColor = color + } + } + appendCodePoint(0x2800 + cell.bits) + } + if (activeColor.isNotEmpty()) append(RESET) + } + + private fun brailleCell(column: Int, row: Int, progress: Double): BrailleCell { + var bits = 0 + var containsLogo = false + DOTS.forEach { dot -> + val x = column * 2 + dot.x + val y = row * 4 + dot.y + val logoDot = isLogoDot(x, y, progress) + val backgroundDot = isBackgroundDot(x, y, progress) + if (logoDot || backgroundDot) bits = bits or dot.bit + containsLogo = containsLogo || logoDot + } + return BrailleCell(bits, containsLogo) + } + + private fun isLogoDot(x: Int, y: Int, progress: Double): Boolean { + val scale = 0.43 + val left = (DOT_WIDTH - 191.0 * scale) / 2.0 + val top = (DOT_HEIGHT - 139.4 * scale) / 2.0 + val sourceX = (x - left) / scale + val sourceY = (y - top) / scale + val reveal = (sourceX / 191.0) * 0.8 + 0.1 + return progress >= reveal && logo.contains(sourceX, sourceY) + } + + private fun isBackgroundDot(x: Int, y: Int, progress: Double): Boolean { + val centerX = DOT_WIDTH / 2.0 + val centerY = DOT_HEIGHT / 2.0 + val radius = hypot((x - centerX) / centerX, (y - centerY) / centerY) + val radialReveal = progress * 1.65 + if (radius > radialReveal) return false + + val phase = ((seed xor (seed ushr 32)) and 0xffff).toDouble() / 0xffff * 2.0 * PI + val diagonal = (x * 3L + y * 5L + seed).mod(17L) < 2L + val wave = sin(x * 0.19 + y * 0.13 + phase + progress * PI * 2.0) > 0.82 + return diagonal || wave + } +} + +suspend fun showSplash( + terminal: TerminalIO, + capabilities: SplashCapabilities, + frameDelayMillis: Long = 60, +) { + if (!capabilities.interactive) return + + val renderer = SplashRenderer() + if (!capabilities.ansi || capabilities.noColor) { + terminal.println(renderer.frame(1.0, capabilities).joinToString("\n")) + return + } + + terminal.print("\u001b[?25l") + try { + repeat(FRAME_COUNT) { index -> + if (index > 0) terminal.print("\u001b[${SPLASH_HEIGHT}A") + val progress = (index + 1).toDouble() / FRAME_COUNT + terminal.print(renderer.frame(progress, capabilities).joinToString("\n", postfix = "\n")) + if (frameDelayMillis > 0) delay(frameDelayMillis) + } + } finally { + terminal.print("$RESET\u001b[?25h\n") + } +} + +private data class BrailleCell(val bits: Int, val logo: Boolean) +private data class BrailleDot(val x: Int, val y: Int, val bit: Int) + +private val DOTS = listOf( + BrailleDot(0, 0, 0x01), + BrailleDot(0, 1, 0x02), + BrailleDot(0, 2, 0x04), + BrailleDot(1, 0, 0x08), + BrailleDot(1, 1, 0x10), + BrailleDot(1, 2, 0x20), + BrailleDot(0, 3, 0x40), + BrailleDot(1, 3, 0x80), +) + +private fun parsePath(data: String): Path2D.Double { + val tokens = PATH_TOKEN.findAll(data).map(MatchResult::value).toList() + val path = Path2D.Double(Path2D.WIND_NON_ZERO) + var index = 0 + var command = ' ' + while (index < tokens.size) { + val token = tokens[index] + if (token.length == 1 && token[0].isLetter()) { + command = token[0] + index++ + if (command == 'Z') path.closePath() + continue + } + fun coordinate(): Double = tokens[index++].toDouble() + when (command) { + 'M' -> { + path.moveTo(coordinate(), coordinate()) + command = 'L' + } + 'L' -> path.lineTo(coordinate(), coordinate()) + 'C' -> path.curveTo( + coordinate(), coordinate(), + coordinate(), coordinate(), + coordinate(), coordinate(), + ) + else -> error("Unsupported logo path command: $command") + } + } + return path +} + +private val PATH_TOKEN = Regex("[MLCZ]|[-+]?(?:\\d*\\.\\d+|\\d+\\.?\\d*)") + +// Vendored from app/src/main/res/drawable/ic_latch.xml (191 x 139.4 viewport). +private val LOGO_PATHS = listOf( + "M69.54,92.49L88.83,110.95L69.07,129.18C62.04,135.36 53.07,135.48 45.07,132.96C42.3,132.09 39.88,130.39 37.78,128.38C26.66,117.72 21.93,112.41 12.83,101.92C9.57,98.15 6.42,94.21 4.26,89.71C-0.43,79.92 -1.02,70.66 1.37,56.2C2.31,50.52 4.14,44.99 7.26,40.16C12.92,31.39 21.31,22.15 36.22,7.06C37.88,5.38 39.76,3.9 41.89,2.88C51.42,-1.67 57.69,-0.61 68.13,4.53L112.1,46.9C113.12,47.88 113.96,49.05 114.51,50.36C120.19,63.79 118.22,70.78 113.64,81.9C97.53,66.53 72.25,43.68 72.25,43.68C69.62,42.1 54.96,37.46 47.2,45.92C39.44,54.38 32.58,77.36 49.2,90.49C55.53,95.45 60.1,95.56 69.54,92.49Z", + "M121.46,46.91L102.17,28.45L121.93,10.22C130.19,2.95 141.13,4.06 150.03,7.98C164.43,21.62 168.85,26.72 180.26,39.9L180.71,40.42C182.29,42.23 183.75,44.15 184.94,46.24C191.56,57.95 192.39,68.01 189.2,85.64C188.47,89.68 187.17,93.62 185.05,97.14C179.65,106.11 171.69,115.14 157.03,130.05C153.88,133.25 150.36,136.19 146.17,137.78C140.34,139.98 135.56,139.86 129.76,137.84C125.06,136.2 121.04,133.11 117.46,129.65L78.9,92.49C77.88,91.51 77.04,90.34 76.49,89.04C70.81,75.61 72.78,68.61 77.36,57.49C93.47,72.86 118.75,95.71 118.75,95.71C121.38,97.29 136.04,101.94 143.8,93.47C151.56,85.01 158.42,62.04 141.8,48.91C135.47,43.95 130.9,43.83 121.46,46.91Z", +) diff --git a/cli/src/main/kotlin/com/vinnovateit/latch/cli/SystemCliLifecycle.kt b/cli/src/main/kotlin/com/vinnovateit/latch/cli/SystemCliLifecycle.kt new file mode 100644 index 00000000..08864f9d --- /dev/null +++ b/cli/src/main/kotlin/com/vinnovateit/latch/cli/SystemCliLifecycle.kt @@ -0,0 +1,133 @@ +package com.vinnovateit.latch.cli + +import com.vinnovateit.latch.core.runtime.AcquireResult +import com.vinnovateit.latch.core.runtime.INSTANCE_PROTOCOL_VERSION +import com.vinnovateit.latch.core.runtime.InstanceCoordinator +import com.vinnovateit.latch.core.runtime.InstanceResponse +import com.vinnovateit.latch.core.runtime.OwnerKind +import com.vinnovateit.latch.core.runtime.RuntimeCommand +import com.vinnovateit.latch.desktop.AppPaths +import java.io.File + +internal fun createSystemCliLifecycle(): CliLifecycle { + val command = resolveDaemonCommand() + command.error?.let { return FailedCliLifecycle(it) } + val resolvedCommand = command.value ?: return FailedCliLifecycle("Unable to resolve the Latch CLI executable.") + val daemonCommand = if (AppPaths.isWindows) windowsHiddenCommand(resolvedCommand) else resolvedCommand + val startup = when { + AppPaths.isWindows -> WindowsLoginStartup() + AppPaths.isLinux -> LinuxLoginStartup(linuxConfigDirectory()) + else -> return FailedCliLifecycle("Persistent CLI background mode is supported on Windows and Linux.") + } + return PersistentCliLifecycle( + startup = startup, + launcher = SystemDaemonLauncher(File(AppPaths.logsDir, "latch-cli-daemon.log")), + owners = CoordinatedRuntimeOwnerControl(AppPaths.dataDir), + daemonCommand = daemonCommand, + ) +} + +internal fun windowsHiddenCommand(command: List): List = listOf( + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + command.joinToString(" ") { "'${it.replace("'", "''")}'" }.let { "& $it" }, +) + +internal fun resolveDaemonCommand( + processCommand: String? = ProcessHandle.current().info().command().orElse(null), + classPath: String = System.getProperty("java.class.path").orEmpty(), +): OperationResult> { + val executable = processCommand?.takeIf(String::isNotBlank) + ?: return OperationResult(error = "Unable to determine the current Latch CLI executable.") + val name = File(executable).nameWithoutExtension + val command = if (name.equals("java", ignoreCase = true) || name.equals("javaw", ignoreCase = true)) { + if (classPath.isBlank()) return OperationResult(error = "Unable to determine the Latch CLI classpath.") + listOf(executable, "-cp", classPath, "com.vinnovateit.latch.cli.MainKt", "--daemon-process") + } else { + listOf(executable, "--daemon-process") + } + return OperationResult(command) +} + +private fun linuxConfigDirectory(): File { + val path = System.getenv("XDG_CONFIG_HOME")?.takeIf(String::isNotBlank) + ?: File(System.getProperty("user.home"), ".config").absolutePath + return File(path) +} + +private class FailedCliLifecycle(private val message: String) : CliLifecycle { + override suspend fun activate() = OperationResult(error = message) + override suspend fun deactivate() = OperationResult(error = message) +} + +internal class SystemDaemonLauncher(private val logFile: File) : DaemonLauncher { + override fun launch(command: List): OperationResult = runCatching { + logFile.parentFile?.mkdirs() + val processBuilder = ProcessBuilder(command) + sanitizeDaemonEnvironment(processBuilder.environment()) + processBuilder.redirectInput(ProcessBuilder.Redirect.from(NULL_INPUT)) + .redirectOutput(ProcessBuilder.Redirect.appendTo(logFile)) + .redirectError(ProcessBuilder.Redirect.appendTo(logFile)) + .start() + OperationResult(Unit) + }.getOrElse { OperationResult(error = it.message ?: "Unable to start Latch in the background.") } + + private companion object { + val NULL_INPUT: File = if (AppPaths.isWindows) File("NUL") else File("/dev/null") + } +} + +internal fun sanitizeDaemonEnvironment(environment: MutableMap) { + environment.remove("_JPACKAGE_LAUNCHER") +} + +internal class CoordinatedRuntimeOwnerControl( + private val dataDir: File, +) : RuntimeOwnerControl { + override suspend fun activeOwner(): OperationResult = when (val acquired = inspect()) { + is AcquireResult.Owner -> { + acquired.coordinator.close() + OperationResult(null) + } + is AcquireResult.Existing -> { + val response = acquired.client.send(RuntimeCommand.PING) + if (response.ok) OperationResult(acquired.metadata.ownerKind) + else OperationResult(error = response.message.ifBlank { response.code }) + } + is AcquireResult.Failure -> OperationResult(error = acquired.message) + } + + override suspend fun stopCliDaemon(): OperationResult = when (val acquired = inspect()) { + is AcquireResult.Owner -> { + acquired.coordinator.close() + OperationResult(Unit) + } + is AcquireResult.Existing -> { + if (acquired.metadata.ownerKind != OwnerKind.CLI_DAEMON) { + OperationResult(Unit) + } else { + val response = acquired.client.send(RuntimeCommand.DEACTIVATE) + if (response.ok) OperationResult(Unit) + else OperationResult(error = response.message.ifBlank { response.code }) + } + } + is AcquireResult.Failure -> OperationResult(error = acquired.message) + } + + private fun inspect(): AcquireResult = InstanceCoordinator.tryAcquire( + dataDir = dataDir, + ownerKind = OwnerKind.CLI_ONESHOT, + ) { request -> + InstanceResponse( + requestId = request.requestId, + ok = false, + code = "OWNER_CHANGED", + message = "Temporary lifecycle probe cannot serve commands.", + data = mapOf("protocol" to INSTANCE_PROTOCOL_VERSION.toString()), + ) + } +} diff --git a/cli/src/test/kotlin/com/vinnovateit/latch/cli/CliCommandTest.kt b/cli/src/test/kotlin/com/vinnovateit/latch/cli/CliCommandTest.kt new file mode 100644 index 00000000..553aed82 --- /dev/null +++ b/cli/src/test/kotlin/com/vinnovateit/latch/cli/CliCommandTest.kt @@ -0,0 +1,130 @@ +package com.vinnovateit.latch.cli + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class CliCommandTest { + @Test + fun `no arguments selects first-run bootstrap`() { + val result = assertIs(parseCommand(emptyArray())) + assertEquals("Bootstrap", result.command::class.simpleName) + } + + @Test + fun `activate and deactivate select lifecycle commands`() { + val cases = mapOf( + "activate" to "Activate", + "deactivate" to "Deactivate", + ) + + cases.forEach { (argument, expectedName) -> + val result = assertIs(parseCommand(arrayOf(argument)), argument) + assertEquals(expectedName, result.command::class.simpleName, argument) + } + } + + @Test + fun `internal daemon process command is parsed but omitted from help`() { + val result = assertIs(parseCommand(arrayOf("--daemon-process"))) + + assertEquals("DaemonProcess", result.command::class.simpleName) + assertFalse(CliOutput.help.contains("--daemon-process")) + } + + @Test + fun `one-shot flags select their command`() { + val cases = mapOf( + "--status" to CliCommand.Status, + "--login" to CliCommand.Login, + "--logout" to CliCommand.Logout, + "--history" to CliCommand.History, + "--set-credentials" to CliCommand.SetCredentials, + "--settings" to CliCommand.GetSettings, + "--help" to CliCommand.Help, + "--version" to CliCommand.Version, + ) + + cases.forEach { (argument, expected) -> + assertEquals(ParseResult.Success(expected), parseCommand(arrayOf(argument)), argument) + } + } + + @Test + fun `settings parses auto-login values`() { + assertEquals( + ParseResult.Success(CliCommand.SetAutoLogin(enabled = true)), + parseCommand(arrayOf("--settings", "set", "auto-login", "on")), + ) + assertEquals( + ParseResult.Success(CliCommand.SetAutoLogin(enabled = false)), + parseCommand(arrayOf("--settings", "set", "auto-login", "off")), + ) + } + + @Test + fun `settings parses and trims allowed ssids`() { + assertEquals( + ParseResult.Success(CliCommand.SetAllowedSsids(linkedSetOf("VIT", "G-VIT"))), + parseCommand(arrayOf("--settings", "set", "allowed-ssids", " VIT, G-VIT ")), + ) + } + + @Test + fun `duplicate allowed ssids are collapsed`() { + assertEquals( + ParseResult.Success(CliCommand.SetAllowedSsids(linkedSetOf("VIT", "G-VIT"))), + parseCommand(arrayOf("--settings", "set", "allowed-ssids", "VIT,G-VIT,VIT")), + ) + } + + @Test + fun `empty allowed ssid list is rejected`() { + assertFailure(arrayOf("--settings", "set", "allowed-ssids", ""), "must not be empty") + assertFailure(arrayOf("--settings", "set", "allowed-ssids", " "), "must not be empty") + } + + @Test + fun `empty allowed ssid entry is rejected`() { + assertFailure(arrayOf("--settings", "set", "allowed-ssids", "VIT,,G-VIT"), "must not contain empty") + assertFailure(arrayOf("--settings", "set", "allowed-ssids", "VIT,"), "must not contain empty") + } + + @Test + fun `invalid auto-login value is rejected`() { + assertFailure(arrayOf("--settings", "set", "auto-login", "yes"), "on or off") + } + + @Test + fun `unknown command is rejected`() { + assertFailure(arrayOf("--connect"), "Unknown command") + } + + @Test + fun `unknown settings key is rejected`() { + assertFailure(arrayOf("--settings", "set", "theme", "dark"), "Unknown settings key") + } + + @Test + fun `incomplete settings command is rejected`() { + assertFailure(arrayOf("--settings", "set"), "Usage") + assertFailure(arrayOf("--settings", "set", "auto-login"), "Usage") + } + + @Test + fun `extra arguments are rejected`() { + assertFailure(arrayOf("--status", "extra"), "does not accept arguments") + assertFailure(arrayOf("--settings", "extra"), "Usage") + assertFailure(arrayOf("--settings", "set", "auto-login", "on", "extra"), "Usage") + } + + private fun assertFailure(args: Array, expectedMessagePart: String) { + val result = assertIs(parseCommand(args)) + assertTrue( + result.message.contains(expectedMessagePart, ignoreCase = true), + "Expected '${result.message}' to contain '$expectedMessagePart'", + ) + } +} diff --git a/cli/src/test/kotlin/com/vinnovateit/latch/cli/CliRunnerTest.kt b/cli/src/test/kotlin/com/vinnovateit/latch/cli/CliRunnerTest.kt new file mode 100644 index 00000000..2f0ea6f2 --- /dev/null +++ b/cli/src/test/kotlin/com/vinnovateit/latch/cli/CliRunnerTest.kt @@ -0,0 +1,370 @@ +package com.vinnovateit.latch.cli + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CliRunnerTest { + @Test + fun `usage failure exits two without creating a backend`() = runBlocking { + val terminal = RecordingTerminal() + var backendCreated = false + + val exitCode = runCli(arrayOf("--unknown"), terminal) { + backendCreated = true + FakeBackend() + } + + assertEquals(2, exitCode) + assertFalse(backendCreated) + assertTrue(terminal.output.startsWith("error: Unknown command: --unknown\n")) + assertTrue(terminal.output.contains("Usage: latch-cli [command]")) + } + + @Test + fun `help prints usage without creating a backend`() = runBlocking { + val terminal = RecordingTerminal() + var backendCreated = false + + val exitCode = CliRunner(terminal, { + backendCreated = true + FakeBackend() + }).run(CliCommand.Help) + + assertEquals(0, exitCode) + assertFalse(backendCreated) + assertTrue(terminal.output.contains("Usage: latch-cli [command]")) + assertTrue(terminal.output.contains("--history")) + assertTrue(terminal.output.contains("--settings set auto-login ")) + } + + @Test + fun `version prints version without creating a backend`() = runBlocking { + val terminal = RecordingTerminal() + var backendCreated = false + + val exitCode = CliRunner(terminal, { + backendCreated = true + FakeBackend() + }, version = "9.8.7").run(CliCommand.Version) + + assertEquals(0, exitCode) + assertFalse(backendCreated) + assertEquals("latch-cli 9.8.7\n", terminal.output) + } + + @Test + fun `configured bootstrap prints help and does not activate`() = runBlocking { + val terminal = RecordingTerminal() + val backend = FakeBackend(setupResult = OperationResult(true)) + val lifecycle = FakeLifecycle() + + val exitCode = CliRunner(terminal, { backend }, lifecycle = lifecycle).run(CliCommand.Bootstrap) + + assertEquals(0, exitCode) + assertTrue(terminal.output.contains("Usage: latch-cli [command]")) + assertFalse(lifecycle.activated) + assertTrue(backend.closed) + } + + @Test + fun `first bootstrap saves credentials and activates background daemon`() = runBlocking { + val secret = charArrayOf('s', 'e', 'c', 'r', 'e', 't') + val terminal = RecordingTerminal(lines = ArrayDeque(listOf("22BCE0001")), secrets = ArrayDeque(listOf(secret))) + val backend = FakeBackend(setupResult = OperationResult(false)) + val lifecycle = FakeLifecycle() + + val exitCode = CliRunner(terminal, { backend }, lifecycle = lifecycle).run(CliCommand.Bootstrap) + + assertEquals(0, exitCode) + assertEquals("22BCE0001", backend.credentialUserId) + assertTrue(lifecycle.activated) + assertTrue(terminal.output.contains("Welcome to Latch.")) + assertTrue(terminal.output.contains("Latch is running in the background and will start when you log in.")) + } + + @Test + fun `activate and deactivate use persistent lifecycle without backend`() = runBlocking { + val terminal = RecordingTerminal() + var backendCreated = false + val lifecycle = FakeLifecycle() + val runner = CliRunner(terminal, { + backendCreated = true + FakeBackend() + }, lifecycle = lifecycle) + + assertEquals(0, runner.run(CliCommand.Activate)) + assertEquals(0, runner.run(CliCommand.Deactivate)) + + assertFalse(backendCreated) + assertTrue(lifecycle.activated) + assertTrue(lifecycle.deactivated) + assertTrue(terminal.output.contains("Latch is running in the background and will start when you log in.")) + assertTrue(terminal.output.contains("Latch background daemon stopped and login startup disabled.")) + } + + @Test + fun `status has stable human-readable output and closes backend`() = runBlocking { + val terminal = RecordingTerminal() + val backend = FakeBackend( + statusResult = OperationResult( + CliStatus(owner = "desktop", connection = "online", ssid = "VIT", latched = true), + ), + ) + + val exitCode = CliRunner(terminal, { backend }).run(CliCommand.Status) + + assertEquals(0, exitCode) + assertEquals( + "owner: desktop\nconnection: online\nssid: VIT\nlatched: yes\n", + terminal.output, + ) + assertTrue(backend.closed) + } + + @Test + fun `missing ssid is rendered explicitly`() = runBlocking { + val terminal = RecordingTerminal() + val backend = FakeBackend( + statusResult = OperationResult( + CliStatus(owner = "cli", connection = "disconnected", ssid = null, latched = false), + ), + ) + + val exitCode = CliRunner(terminal, { backend }).run(CliCommand.Status) + + assertEquals(0, exitCode) + assertTrue(terminal.output.contains("ssid: none\n")) + assertTrue(terminal.output.contains("latched: no\n")) + } + + @Test + fun `operational failure prints error and exits one`() = runBlocking { + val terminal = RecordingTerminal() + val backend = FakeBackend(statusResult = OperationResult(error = "Wi-Fi is unavailable")) + + val exitCode = CliRunner(terminal, { backend }).run(CliCommand.Status) + + assertEquals(1, exitCode) + assertEquals("error: Wi-Fi is unavailable\n", terminal.output) + assertTrue(backend.closed) + } + + @Test + fun `backend creation failure is an operational error`() = runBlocking { + val terminal = RecordingTerminal() + + val exitCode = CliRunner(terminal, { error("database unavailable") }).run(CliCommand.Status) + + assertEquals(1, exitCode) + assertEquals("error: database unavailable\n", terminal.output) + } + + @Test + fun `history is newest first with stable timestamps`() = runBlocking { + val terminal = RecordingTerminal() + val older = CliSession(start = 1_000, end = 2_000, rx = 10, tx = 20, maxRx = 30, maxTx = 40) + val newer = CliSession(start = 3_000, end = 4_000, rx = 50, tx = 60, maxRx = 70, maxTx = 80) + val backend = FakeBackend(historyResult = OperationResult(listOf(older, newer))) + + val exitCode = CliRunner(terminal, { backend }).run(CliCommand.History) + + assertEquals(0, exitCode) + assertEquals( + "start\tend\trx-bytes\ttx-bytes\tmax-rx-bps\tmax-tx-bps\n" + + "1970-01-01T00:00:03Z\t1970-01-01T00:00:04Z\t50\t60\t70\t80\n" + + "1970-01-01T00:00:01Z\t1970-01-01T00:00:02Z\t10\t20\t30\t40\n", + terminal.output, + ) + } + + @Test + fun `empty history says there are no sessions`() = runBlocking { + val terminal = RecordingTerminal() + val backend = FakeBackend(historyResult = OperationResult(emptyList())) + + val exitCode = CliRunner(terminal, { backend }).run(CliCommand.History) + + assertEquals(0, exitCode) + assertEquals("No sessions.\n", terminal.output) + } + + @Test + fun `settings output sorts ssids`() = runBlocking { + val terminal = RecordingTerminal() + val backend = FakeBackend( + settingsResult = OperationResult(CliSettings(autoLogin = true, allowedSsids = setOf("VIT", "G-VIT"))), + ) + + val exitCode = CliRunner(terminal, { backend }).run(CliCommand.GetSettings) + + assertEquals(0, exitCode) + assertEquals("auto-login: on\nallowed-ssids: G-VIT,VIT\n", terminal.output) + } + + @Test + fun `setting auto-login updates backend`() = runBlocking { + val terminal = RecordingTerminal() + val backend = FakeBackend() + + val exitCode = CliRunner(terminal, { backend }).run(CliCommand.SetAutoLogin(enabled = false)) + + assertEquals(0, exitCode) + assertEquals(false, backend.autoLoginValue) + assertEquals("auto-login: off\n", terminal.output) + } + + @Test + fun `setting allowed ssids updates backend`() = runBlocking { + val terminal = RecordingTerminal() + val backend = FakeBackend() + + val exitCode = CliRunner(terminal, { backend }).run(CliCommand.SetAllowedSsids(setOf("VIT", "G-VIT"))) + + assertEquals(0, exitCode) + assertEquals(setOf("VIT", "G-VIT"), backend.allowedSsidsValue) + assertEquals("allowed-ssids: G-VIT,VIT\n", terminal.output) + } + + @Test + fun `credentials are prompted securely and password buffer is cleared`() = runBlocking { + val secret = charArrayOf('s', 'e', 'c', 'r', 'e', 't') + val terminal = RecordingTerminal(lines = ArrayDeque(listOf("22BCE0001")), secrets = ArrayDeque(listOf(secret))) + val backend = FakeBackend() + + val exitCode = CliRunner(terminal, { backend }).run(CliCommand.SetCredentials) + + assertEquals(0, exitCode) + assertEquals("22BCE0001", backend.credentialUserId) + assertContentEquals(charArrayOf('s', 'e', 'c', 'r', 'e', 't'), backend.credentialPassword) + assertTrue(secret.all { it == '\u0000' }) + assertEquals("Credentials saved.\n", terminal.output) + } + + @Test + fun `missing password does not update credentials`() = runBlocking { + val terminal = RecordingTerminal(lines = ArrayDeque(listOf("22BCE0001"))) + val backend = FakeBackend() + + val exitCode = CliRunner(terminal, { backend }).run(CliCommand.SetCredentials) + + assertEquals(1, exitCode) + assertEquals(null, backend.credentialUserId) + assertEquals("error: A password is required.\n", terminal.output) + } + + @Test + fun `successful login and logout use stable output`() = runBlocking { + val cases = mapOf( + CliCommand.Login to "Login completed.\n", + CliCommand.Logout to "Logout completed.\n", + ) + + cases.forEach { (command, expectedOutput) -> + val terminal = RecordingTerminal() + val exitCode = CliRunner(terminal, { FakeBackend() }).run(command) + assertEquals(0, exitCode, command.toString()) + assertEquals(expectedOutput, terminal.output, command.toString()) + } + } + + @Test + fun `splash runs for interactive bootstrap but not background daemon`() = runBlocking { + var splashCount = 0 + val splash: suspend (TerminalIO) -> Unit = { splashCount++ } + + CliRunner(RecordingTerminal(), { FakeBackend() }, splash = splash).run(CliCommand.Status) + assertEquals(0, splashCount) + + CliRunner(RecordingTerminal(), { FakeBackend() }, splash = splash).run(CliCommand.Bootstrap) + assertEquals(1, splashCount) + + CliRunner(RecordingTerminal(), { FakeBackend() }, splash = splash).run(CliCommand.DaemonProcess) + assertEquals(1, splashCount) + } +} + +private class RecordingTerminal( + private val lines: ArrayDeque = ArrayDeque(), + private val secrets: ArrayDeque = ArrayDeque(), +) : TerminalIO { + private val buffer = StringBuilder() + override val interactive: Boolean = true + val output: String get() = buffer.toString() + + override fun print(text: String) { + buffer.append(text) + } + + override fun println(text: String) { + buffer.append(text).append('\n') + } + + override fun readLine(prompt: String): String? = lines.removeFirstOrNull() + + override fun readSecret(prompt: String): CharArray? = secrets.removeFirstOrNull() +} + +private class FakeBackend( + private val statusResult: OperationResult = OperationResult( + CliStatus(owner = "cli", connection = "idle", ssid = null, latched = false), + ), + private val historyResult: OperationResult> = OperationResult(emptyList()), + private val settingsResult: OperationResult = OperationResult( + CliSettings(autoLogin = true, allowedSsids = setOf("VIT")), + ), + private val setupResult: OperationResult = OperationResult(true), +) : CliBackend { + var closed = false + var autoLoginValue: Boolean? = null + var allowedSsidsValue: Set? = null + var credentialUserId: String? = null + var credentialPassword: CharArray? = null + + override suspend fun status(): OperationResult = statusResult + override suspend fun login(): OperationResult = OperationResult(Unit) + override suspend fun logout(): OperationResult = OperationResult(Unit) + override suspend fun history(): OperationResult> = historyResult + override suspend fun settings(): OperationResult = settingsResult + override suspend fun isSetup(): OperationResult = setupResult + + override suspend fun setAutoLogin(enabled: Boolean): OperationResult { + autoLoginValue = enabled + return OperationResult(Unit) + } + + override suspend fun setAllowedSsids(values: Set): OperationResult { + allowedSsidsValue = values + return OperationResult(Unit) + } + + override suspend fun setCredentials(userId: String, password: CharArray): OperationResult { + credentialUserId = userId + credentialPassword = password.copyOf() + return OperationResult(Unit) + } + + override suspend fun runDaemon(): OperationResult = OperationResult(Unit) + + override fun close() { + closed = true + } +} + +private class FakeLifecycle : CliLifecycle { + var activated = false + var deactivated = false + + override suspend fun activate(): OperationResult { + activated = true + return OperationResult(Unit) + } + + override suspend fun deactivate(): OperationResult { + deactivated = true + return OperationResult(Unit) + } +} diff --git a/cli/src/test/kotlin/com/vinnovateit/latch/cli/CoordinatedCliBackendTest.kt b/cli/src/test/kotlin/com/vinnovateit/latch/cli/CoordinatedCliBackendTest.kt new file mode 100644 index 00000000..ca7c4a5a --- /dev/null +++ b/cli/src/test/kotlin/com/vinnovateit/latch/cli/CoordinatedCliBackendTest.kt @@ -0,0 +1,104 @@ +package com.vinnovateit.latch.cli + +import com.vinnovateit.latch.core.runtime.AcquireResult +import com.vinnovateit.latch.core.runtime.InstanceCoordinator +import com.vinnovateit.latch.core.runtime.InstanceRequest +import com.vinnovateit.latch.core.runtime.InstanceResponse +import com.vinnovateit.latch.core.runtime.OwnerKind +import com.vinnovateit.latch.core.runtime.RuntimeCommand +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +class CoordinatedCliBackendTest { + @Test + fun `one-shot command forwards to active cli daemon`() = runBlocking { + val directory = createTempDirectory("latch-cli-forward-").toFile() + val daemon = createCoordinatedCliBackend(CliCommand.DaemonProcess, SilentTerminal, directory) + try { + val oneShot = createCoordinatedCliBackend(CliCommand.Status, SilentTerminal, directory) + try { + val status = oneShot.status() + assertEquals("cli-daemon", status.value?.owner) + } finally { + oneShot.close() + } + } finally { + daemon.close() + directory.deleteRecursively() + } + } + + @Test + fun `desktop takeover stops daemon and releases ownership`() = runBlocking { + val directory = createTempDirectory("latch-cli-takeover-").toFile() + val daemon = createCoordinatedCliBackend(CliCommand.DaemonProcess, SilentTerminal, directory) + val daemonRun = async(Dispatchers.Default) { daemon.runDaemon() } + try { + val existing = assertIs( + InstanceCoordinator.tryAcquire(directory, OwnerKind.DESKTOP, ::echo), + ) + + val response = existing.client.send(RuntimeCommand.TAKE_OVER) + + assertTrue(response.ok) + assertEquals(OperationResult(Unit), withTimeout(2_000) { daemonRun.await() }) + daemon.close() + + val desktop = assertIs( + InstanceCoordinator.tryAcquire(directory, OwnerKind.DESKTOP, ::echo), + ) + desktop.coordinator.close() + } finally { + daemonRun.cancel() + daemon.close() + directory.deleteRecursively() + } + } + + @Test + fun `authenticated deactivate stops daemon and releases ownership`() = runBlocking { + val directory = createTempDirectory("latch-cli-deactivate-").toFile() + val daemon = createCoordinatedCliBackend(CliCommand.DaemonProcess, SilentTerminal, directory) + val daemonRun = async(Dispatchers.Default) { daemon.runDaemon() } + try { + val existing = assertIs( + InstanceCoordinator.tryAcquire(directory, OwnerKind.CLI_ONESHOT, ::echo), + ) + + val response = existing.client.send(RuntimeCommand.DEACTIVATE) + + assertTrue(response.ok) + assertEquals(OperationResult(Unit), withTimeout(2_000) { daemonRun.await() }) + daemon.close() + val next = assertIs( + InstanceCoordinator.tryAcquire(directory, OwnerKind.CLI_ONESHOT, ::echo), + ) + next.coordinator.close() + } finally { + daemonRun.cancel() + daemon.close() + directory.deleteRecursively() + } + } + + private suspend fun echo(request: InstanceRequest) = InstanceResponse( + requestId = request.requestId, + ok = true, + code = "OK", + ) +} + +private object SilentTerminal : TerminalIO { + override val interactive = false + override fun print(text: String) = Unit + override fun println(text: String) = Unit + override fun readLine(prompt: String): String? = null + override fun readSecret(prompt: String): CharArray? = null +} diff --git a/cli/src/test/kotlin/com/vinnovateit/latch/cli/LoginStartupTest.kt b/cli/src/test/kotlin/com/vinnovateit/latch/cli/LoginStartupTest.kt new file mode 100644 index 00000000..abb128c5 --- /dev/null +++ b/cli/src/test/kotlin/com/vinnovateit/latch/cli/LoginStartupTest.kt @@ -0,0 +1,84 @@ +package com.vinnovateit.latch.cli + +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LoginStartupTest { + @Test + fun `linux startup writes and removes a distinct XDG desktop entry`() { + val directory = createTempDirectory("latch-cli-autostart-").toFile() + val startup = LinuxLoginStartup(directory) + val command = listOf("/opt/Latch CLI/latch-cli", "--daemon-process") + + assertEquals(OperationResult(Unit), startup.enable(command)) + + val entry = directory.resolve("autostart/latch-cli.desktop") + assertTrue(entry.isFile) + assertTrue(entry.readText().contains("Name=Latch CLI")) + assertTrue(entry.readText().contains("Exec=\"/opt/Latch CLI/latch-cli\" \"--daemon-process\"")) + assertTrue(entry.readText().contains("Terminal=false")) + + assertEquals(OperationResult(Unit), startup.disable()) + assertFalse(entry.exists()) + directory.deleteRecursively() + } + + @Test + fun `windows startup uses a distinct per-user Run value`() { + val executor = RecordingCommandExecutor() + val startup = WindowsLoginStartup(executor) + + assertEquals( + OperationResult(Unit), + startup.enable(listOf("C:\\Program Files\\Latch CLI\\latch-cli.exe", "--daemon-process")), + ) + assertEquals(OperationResult(Unit), startup.disable()) + + assertEquals( + listOf( + "reg.exe", "ADD", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", + "/v", "Latch CLI", "/t", "REG_SZ", "/d", + "\"C:\\Program Files\\Latch CLI\\latch-cli.exe\" \"--daemon-process\"", "/f", + ), + executor.commands[0], + ) + assertEquals( + listOf( + "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", + "${'$'}ErrorActionPreference = 'Stop'; " + + "${'$'}key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey(" + + "'Software\\Microsoft\\Windows\\CurrentVersion\\Run', ${'$'}true); " + + "if (${'$'}null -ne ${'$'}key) { try { " + + "if (${'$'}null -ne ${'$'}key.GetValue('Latch CLI', ${'$'}null, " + + "[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)) { " + + "${'$'}key.DeleteValue('Latch CLI', ${'$'}false) } } finally { ${'$'}key.Dispose() } }", + ), + executor.commands[1], + ) + } + + @Test + fun `windows startup propagates registry removal failures`() { + val executor = RecordingCommandExecutor( + result = OperationResult(error = "Registry access was denied."), + ) + + val result = WindowsLoginStartup(executor).disable() + + assertEquals(OperationResult(error = "Registry access was denied."), result) + } +} + +private class RecordingCommandExecutor( + private val result: OperationResult = OperationResult(Unit), +) : CommandExecutor { + val commands = mutableListOf>() + + override fun execute(command: List): OperationResult { + commands += command + return result + } +} diff --git a/cli/src/test/kotlin/com/vinnovateit/latch/cli/PersistentCliLifecycleTest.kt b/cli/src/test/kotlin/com/vinnovateit/latch/cli/PersistentCliLifecycleTest.kt new file mode 100644 index 00000000..3c520181 --- /dev/null +++ b/cli/src/test/kotlin/com/vinnovateit/latch/cli/PersistentCliLifecycleTest.kt @@ -0,0 +1,196 @@ +package com.vinnovateit.latch.cli + +import com.vinnovateit.latch.core.runtime.OwnerKind +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class PersistentCliLifecycleTest { + @Test + fun `activate enables login startup and launches daemon when no owner exists`() = runBlocking { + val startup = FakeLoginStartup() + val launcher = FakeDaemonLauncher() + val owners = FakeOwnerControl(ArrayDeque(listOf(null, OwnerKind.CLI_DAEMON))) + val command = listOf("/opt/latch-cli/bin/latch-cli", "--daemon-process") + val lifecycle = PersistentCliLifecycle(startup, launcher, owners, command, retryDelayMillis = 0, attempts = 2) + + assertEquals(OperationResult(Unit), lifecycle.activate()) + + assertEquals(command, startup.enabledCommand) + assertEquals(command, launcher.launchedCommand) + } + + @Test + fun `activate is idempotent when an owner is already running`() = runBlocking { + val startup = FakeLoginStartup() + val launcher = FakeDaemonLauncher() + val owners = FakeOwnerControl(ArrayDeque(listOf(OwnerKind.DESKTOP))) + val lifecycle = PersistentCliLifecycle(startup, launcher, owners, listOf("latch-cli", "--daemon-process")) + + assertEquals(OperationResult(Unit), lifecycle.activate()) + + assertTrue(startup.enabled) + assertFalse(launcher.launched) + } + + @Test + fun `activate retries transient owner metadata race while daemon starts`() = runBlocking { + val startup = FakeLoginStartup() + val owners = FakeOwnerControl( + ArrayDeque(listOf(null, OwnerKind.CLI_DAEMON)), + transientErrorsAtCalls = setOf(2), + ) + val lifecycle = PersistentCliLifecycle( + startup, + FakeDaemonLauncher(), + owners, + listOf("latch-cli", "--daemon-process"), + retryDelayMillis = 0, + attempts = 3, + ) + + assertEquals(OperationResult(Unit), lifecycle.activate()) + assertFalse(startup.disabled) + } + + @Test + fun `activate waits out a one-shot owner and verifies a durable daemon`() = runBlocking { + val startup = FakeLoginStartup() + val launcher = FakeDaemonLauncher() + val owners = FakeOwnerControl( + ArrayDeque(listOf(OwnerKind.CLI_ONESHOT, OwnerKind.CLI_ONESHOT, null, OwnerKind.CLI_DAEMON)), + ) + val lifecycle = PersistentCliLifecycle( + startup, + launcher, + owners, + listOf("latch-cli", "--daemon-process"), + retryDelayMillis = 0, + attempts = 4, + ) + + assertEquals(OperationResult(Unit), lifecycle.activate()) + assertTrue(launcher.launched) + assertFalse(startup.disabled) + } + + @Test + fun `failed daemon startup rolls back login startup`() = runBlocking { + val startup = FakeLoginStartup() + val owners = FakeOwnerControl(ArrayDeque(listOf(null, null, null))) + val lifecycle = PersistentCliLifecycle( + startup, + FakeDaemonLauncher(), + owners, + listOf("latch-cli", "--daemon-process"), + retryDelayMillis = 0, + attempts = 2, + ) + + val result = lifecycle.activate() + + assertEquals("Latch background daemon did not become ready.", result.error) + assertTrue(startup.disabled) + } + + @Test + fun `deactivate disables startup and stops cli daemon`() = runBlocking { + val startup = FakeLoginStartup() + val owners = FakeOwnerControl(ArrayDeque(listOf(OwnerKind.CLI_DAEMON, null))) + val lifecycle = PersistentCliLifecycle( + startup, + FakeDaemonLauncher(), + owners, + listOf("latch-cli", "--daemon-process"), + retryDelayMillis = 0, + attempts = 2, + ) + + assertEquals(OperationResult(Unit), lifecycle.deactivate()) + + assertTrue(startup.disabled) + assertTrue(owners.stopRequested) + } + + @Test + fun `deactivate leaves desktop owner running`() = runBlocking { + val startup = FakeLoginStartup() + val owners = FakeOwnerControl(ArrayDeque(listOf(OwnerKind.DESKTOP))) + val lifecycle = PersistentCliLifecycle(startup, FakeDaemonLauncher(), owners, listOf("latch-cli")) + + assertEquals(OperationResult(Unit), lifecycle.deactivate()) + + assertTrue(startup.disabled) + assertFalse(owners.stopRequested) + } + + @Test + fun `deactivate retries transient owner metadata race while daemon exits`() = runBlocking { + val startup = FakeLoginStartup() + val owners = FakeOwnerControl( + ArrayDeque(listOf(OwnerKind.CLI_DAEMON, null)), + transientErrorsAtCalls = setOf(2), + ) + val lifecycle = PersistentCliLifecycle( + startup, + FakeDaemonLauncher(), + owners, + listOf("latch-cli"), + retryDelayMillis = 0, + attempts = 2, + ) + + assertEquals(OperationResult(Unit), lifecycle.deactivate()) + } +} + +private class FakeLoginStartup : LoginStartup { + var enabled = false + var disabled = false + var enabledCommand: List? = null + + override fun enable(command: List): OperationResult { + enabled = true + enabledCommand = command + return OperationResult(Unit) + } + + override fun disable(): OperationResult { + disabled = true + return OperationResult(Unit) + } +} + +private class FakeDaemonLauncher : DaemonLauncher { + var launched = false + var launchedCommand: List? = null + + override fun launch(command: List): OperationResult { + launched = true + launchedCommand = command + return OperationResult(Unit) + } +} + +private class FakeOwnerControl( + private val owners: ArrayDeque, + private val transientErrorsAtCalls: Set = emptySet(), +) : RuntimeOwnerControl { + var stopRequested = false + private var activeOwnerCalls = 0 + + override suspend fun activeOwner(): OperationResult { + activeOwnerCalls++ + if (activeOwnerCalls in transientErrorsAtCalls) { + return OperationResult(error = "Runtime lock is held but owner metadata is unavailable.") + } + return OperationResult(if (owners.size > 1) owners.removeFirst() else owners.firstOrNull()) + } + + override suspend fun stopCliDaemon(): OperationResult { + stopRequested = true + return OperationResult(Unit) + } +} diff --git a/cli/src/test/kotlin/com/vinnovateit/latch/cli/RemoteCliBackendTest.kt b/cli/src/test/kotlin/com/vinnovateit/latch/cli/RemoteCliBackendTest.kt new file mode 100644 index 00000000..e55e25de --- /dev/null +++ b/cli/src/test/kotlin/com/vinnovateit/latch/cli/RemoteCliBackendTest.kt @@ -0,0 +1,105 @@ +package com.vinnovateit.latch.cli + +import com.vinnovateit.latch.core.runtime.AcquireResult +import com.vinnovateit.latch.core.runtime.InstanceCoordinator +import com.vinnovateit.latch.core.runtime.OwnerKind +import com.vinnovateit.latch.core.runtime.RuntimeCommandService +import com.vinnovateit.latch.core.runtime.RuntimeCommandTarget +import com.vinnovateit.latch.core.runtime.RuntimeOperation +import com.vinnovateit.latch.core.runtime.RuntimeSessionRecord +import com.vinnovateit.latch.core.runtime.RuntimeSettingsSnapshot +import com.vinnovateit.latch.core.runtime.RuntimeSnapshot +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlinx.coroutines.runBlocking + +class RemoteCliBackendTest { + @Test + fun `remote backend maps status history and settings`() = runBlocking { + withRemoteBackend { backend, _ -> + assertEquals(OperationResult(true), backend.isSetup()) + assertEquals( + OperationResult(CliStatus("desktop", "connected", "VIT", true)), + backend.status(), + ) + assertEquals( + OperationResult(listOf(CliSession(1, 2, 3, 4, 5, 6))), + backend.history(), + ) + assertEquals( + OperationResult(CliSettings(true, setOf("G-VIT", "VIT"))), + backend.settings(), + ) + } + } + + @Test + fun `remote backend forwards mutations and credentials`() = runBlocking { + withRemoteBackend { backend, target -> + val password = charArrayOf('s', 'e', 'c', 'r', 'e', 't') + + assertEquals(OperationResult(Unit), backend.setAutoLogin(false)) + assertEquals(OperationResult(Unit), backend.setAllowedSsids(setOf("VIT", "G-VIT"))) + assertEquals(OperationResult(Unit), backend.setCredentials("22BCE0001", password)) + + assertEquals(false, target.autoLogin) + assertEquals(setOf("VIT", "G-VIT"), target.ssids) + assertEquals("22BCE0001", target.userId) + assertEquals("secret", target.password) + assertContentEquals(charArrayOf('s', 'e', 'c', 'r', 'e', 't'), password) + } + } + + @Test + fun `remote operational errors are preserved`() = runBlocking { + val target = RemoteTarget(loginOperation = RuntimeOperation(false, "NO_WIFI", "Wi-Fi is unavailable.")) + withRemoteBackend(target) { backend, _ -> + assertEquals(OperationResult(error = "Wi-Fi is unavailable."), backend.login()) + } + } + + private suspend fun withRemoteBackend( + target: RemoteTarget = RemoteTarget(), + block: suspend (RemoteCliBackend, RemoteTarget) -> Unit, + ) { + val directory = createTempDirectory("latch-remote-cli-").toFile() + val service = RuntimeCommandService(OwnerKind.DESKTOP, target) + val owner = assertIs( + InstanceCoordinator.tryAcquire(directory, OwnerKind.DESKTOP, service::execute), + ) + try { + val existing = assertIs( + InstanceCoordinator.tryAcquire(directory, OwnerKind.CLI_ONESHOT, service::execute), + ) + block(RemoteCliBackend(existing.client), target) + } finally { + owner.coordinator.close() + directory.deleteRecursively() + } + } +} + +private class RemoteTarget( + private val loginOperation: RuntimeOperation = RuntimeOperation(true), +) : RuntimeCommandTarget { + var autoLogin: Boolean? = null + var ssids: Set? = null + var userId: String? = null + var password: String? = null + + override suspend fun isSetup() = true + override suspend fun snapshot() = RuntimeSnapshot("connected", "VIT", true) + override suspend fun login() = loginOperation + override suspend fun logout() = RuntimeOperation(true) + override suspend fun history() = listOf(RuntimeSessionRecord(1, 2, 3, 4, 5, 6)) + override suspend fun settings() = RuntimeSettingsSnapshot(true, setOf("VIT", "G-VIT")) + override suspend fun setAutoLogin(enabled: Boolean) { autoLogin = enabled } + override suspend fun setAllowedSsids(values: Set) { ssids = values } + override suspend fun setCredentials(userId: String, password: String) { + this.userId = userId + this.password = password + } +} diff --git a/cli/src/test/kotlin/com/vinnovateit/latch/cli/SplashTest.kt b/cli/src/test/kotlin/com/vinnovateit/latch/cli/SplashTest.kt new file mode 100644 index 00000000..f728d35a --- /dev/null +++ b/cli/src/test/kotlin/com/vinnovateit/latch/cli/SplashTest.kt @@ -0,0 +1,107 @@ +package com.vinnovateit.latch.cli + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SplashTest { + @Test + fun `same seed and progress produce the same frame`() { + val capabilities = SplashCapabilities(interactive = true, ansi = false, trueColor = false, noColor = true) + + val first = SplashRenderer(seed = 42).frame(progress = 0.65, capabilities) + val second = SplashRenderer(seed = 42).frame(progress = 0.65, capabilities) + + assertEquals(first, second) + } + + @Test + fun `frame is a 56 by 20 braille composition`() { + val capabilities = SplashCapabilities(interactive = true, ansi = false, trueColor = false, noColor = true) + + val frame = SplashRenderer().frame(progress = 1.0, capabilities) + + assertEquals(20, frame.size) + assertTrue(frame.all { it.length == 56 }) + assertTrue(frame.joinToString("").any { it.code in 0x2801..0x28ff }) + } + + @Test + fun `no color frame contains no terminal escapes`() { + val capabilities = SplashCapabilities(interactive = true, ansi = true, trueColor = true, noColor = true) + + val frame = SplashRenderer().frame(progress = 1.0, capabilities) + + assertFalse(frame.joinToString("").contains('\u001b')) + } + + @Test + fun `true color uses the Latch brand red`() { + val capabilities = SplashCapabilities(interactive = true, ansi = true, trueColor = true, noColor = false) + + val frame = SplashRenderer().frame(progress = 1.0, capabilities) + + assertTrue(frame.joinToString("").contains("\u001b[38;2;192;18;33m")) + } + + @Test + fun `basic ansi falls back to standard red`() { + val capabilities = SplashCapabilities(interactive = true, ansi = true, trueColor = false, noColor = false) + + val frame = SplashRenderer().frame(progress = 1.0, capabilities) + + assertTrue(frame.joinToString("").contains("\u001b[31m")) + assertFalse(frame.joinToString("").contains("38;2")) + } + + @Test + fun `noninteractive output skips splash`() = runBlocking { + val terminal = SplashTerminal(interactive = false) + val capabilities = SplashCapabilities(interactive = false, ansi = false, trueColor = false, noColor = true) + + showSplash(terminal, capabilities, frameDelayMillis = 0) + + assertEquals("", terminal.output) + } + + @Test + fun `plain terminal receives one static frame without cursor controls`() = runBlocking { + val terminal = SplashTerminal(interactive = true) + val capabilities = SplashCapabilities(interactive = true, ansi = false, trueColor = false, noColor = true) + + showSplash(terminal, capabilities, frameDelayMillis = 0) + + assertFalse(terminal.output.contains('\u001b')) + assertEquals(20, terminal.output.trimEnd().lines().size) + } + + @Test + fun `animated terminal hides and restores the cursor`() = runBlocking { + val terminal = SplashTerminal(interactive = true) + val capabilities = SplashCapabilities(interactive = true, ansi = true, trueColor = false, noColor = false) + + showSplash(terminal, capabilities, frameDelayMillis = 0) + + assertTrue(terminal.output.startsWith("\u001b[?25l")) + assertTrue(terminal.output.endsWith("\u001b[0m\u001b[?25h\n")) + assertEquals(9, "\u001b[20A".toRegex(RegexOption.LITERAL).findAll(terminal.output).count()) + } +} + +private class SplashTerminal(override val interactive: Boolean) : TerminalIO { + private val buffer = StringBuilder() + val output: String get() = buffer.toString() + + override fun print(text: String) { + buffer.append(text) + } + + override fun println(text: String) { + buffer.append(text).append('\n') + } + + override fun readLine(prompt: String): String? = null + override fun readSecret(prompt: String): CharArray? = null +} diff --git a/cli/src/test/kotlin/com/vinnovateit/latch/cli/SystemCliLifecycleTest.kt b/cli/src/test/kotlin/com/vinnovateit/latch/cli/SystemCliLifecycleTest.kt new file mode 100644 index 00000000..a959cc77 --- /dev/null +++ b/cli/src/test/kotlin/com/vinnovateit/latch/cli/SystemCliLifecycleTest.kt @@ -0,0 +1,55 @@ +package com.vinnovateit.latch.cli + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SystemCliLifecycleTest { + @Test + fun `daemon child does not inherit jpackage internal launcher marker`() { + val environment = mutableMapOf("_JPACKAGE_LAUNCHER" to "0", "PATH" to "/usr/bin") + + sanitizeDaemonEnvironment(environment) + + assertEquals(mapOf("PATH" to "/usr/bin"), environment) + } + + @Test + fun `packaged launcher command starts hidden daemon process`() { + assertEquals( + OperationResult(listOf("/opt/latch-cli/bin/latch-cli", "--daemon-process")), + resolveDaemonCommand("/opt/latch-cli/bin/latch-cli", "ignored"), + ) + } + + @Test + fun `development java command preserves runtime and classpath`() { + assertEquals( + OperationResult( + listOf( + "/usr/lib/jvm/bin/java", + "-cp", + "cli.jar:core.jar", + "com.vinnovateit.latch.cli.MainKt", + "--daemon-process", + ), + ), + resolveDaemonCommand("/usr/lib/jvm/bin/java", "cli.jar:core.jar"), + ) + } + + @Test + fun `windows daemon command launches without a visible console`() { + assertEquals( + listOf( + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + "& 'C:\\Program Files\\Latch CLI\\latch-cli.exe' '--daemon-process'", + ), + windowsHiddenCommand(listOf("C:\\Program Files\\Latch CLI\\latch-cli.exe", "--daemon-process")), + ) + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index a9d33f3b..99587b3f 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,4 +1,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.gradle.language.jvm.tasks.ProcessResources + +val latchVersion = providers.gradleProperty("latchVersion").get() plugins { alias(libs.plugins.kotlin.multiplatform) @@ -24,8 +27,6 @@ kotlin { } sourceSets { - val desktopMain by getting - commonMain.dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) @@ -47,6 +48,9 @@ kotlin { implementation(libs.androidx.preference.ktx) } + val desktopMain by getting + val desktopTest by getting + desktopMain.dependencies { // JVM has no built-in SQLite; Android does, so this stays desktop-only. api(libs.sqlite.bundled) @@ -56,6 +60,10 @@ kotlin { implementation(libs.jna.platform) implementation(libs.slf4j.simple) } + + desktopTest.dependencies { + implementation(kotlin("test")) + } } } @@ -66,4 +74,3 @@ dependencies { add("kspDesktop", libs.room.compiler.desktop) add("kspAndroid", libs.room.compiler.desktop) } - diff --git a/core/src/commonMain/kotlin/com/vinnovateit/latch/core/LatchCore.kt b/core/src/commonMain/kotlin/com/vinnovateit/latch/core/LatchCore.kt deleted file mode 100644 index 83e4c956..00000000 --- a/core/src/commonMain/kotlin/com/vinnovateit/latch/core/LatchCore.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.vinnovateit.latch.core - -object LatchCore { - const val VERSION = "1.3" -} diff --git a/core/src/commonMain/kotlin/com/vinnovateit/latch/core/domain/SessionRepository.kt b/core/src/commonMain/kotlin/com/vinnovateit/latch/core/domain/SessionRepository.kt index 82c383f2..eb361b04 100644 --- a/core/src/commonMain/kotlin/com/vinnovateit/latch/core/domain/SessionRepository.kt +++ b/core/src/commonMain/kotlin/com/vinnovateit/latch/core/domain/SessionRepository.kt @@ -98,7 +98,18 @@ class SessionRepository( } fun stopSession() { - val sessionToFinalize = _liveStatus.value ?: return + val session = finishActiveSession() ?: return + scope.launch { statsDao.insertSession(session) } + } + + /** Completes persistence before returning, for orderly process shutdown. */ + suspend fun stopSessionAndAwait() { + val session = finishActiveSession() ?: return + statsDao.insertSession(session) + } + + private fun finishActiveSession(): Session? { + val sessionToFinalize = _liveStatus.value ?: return null sessionUpdateJob?.cancel() sessionUpdateJob = null @@ -114,22 +125,18 @@ class SessionRepository( // that carried no traffic. Threshold matches Android. if (totalRxBytes + totalTxBytes < 1024) { onSessionChanged?.invoke() - return + return null } - scope.launch { - statsDao.insertSession( - Session( - startTime = sessionToFinalize.startTimeMillis, - endTime = System.currentTimeMillis(), - rxBytes = totalRxBytes, - txBytes = totalTxBytes, - maxRxBps = maxRxBps, - maxTxBps = maxTxBps, - ) - ) - } onSessionChanged?.invoke() + return Session( + startTime = sessionToFinalize.startTimeMillis, + endTime = System.currentTimeMillis(), + rxBytes = totalRxBytes, + txBytes = totalTxBytes, + maxRxBps = maxRxBps, + maxTxBps = maxTxBps, + ) } fun clearHistory() { diff --git a/core/src/commonMain/kotlin/com/vinnovateit/latch/core/engine/LatchEngine.kt b/core/src/commonMain/kotlin/com/vinnovateit/latch/core/engine/LatchEngine.kt index 9614dda1..d9243fcb 100644 --- a/core/src/commonMain/kotlin/com/vinnovateit/latch/core/engine/LatchEngine.kt +++ b/core/src/commonMain/kotlin/com/vinnovateit/latch/core/engine/LatchEngine.kt @@ -10,12 +10,15 @@ import com.vinnovateit.latch.core.wifi.CaptivePortalDetector import com.vinnovateit.latch.core.wifi.ConnectionStatus import com.vinnovateit.latch.core.wifi.ConnectionStatusManager import com.vinnovateit.latch.core.wifi.LoginResult +import com.vinnovateit.latch.core.wifi.isVitCampusSsid +import com.vinnovateit.latch.core.wifi.probeCampusNetwork import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -74,14 +77,6 @@ class LatchEngine( const val HEALTH_CHECK_INTERVAL_MS = 60_000L const val REVALIDATE_DELAY_MS = 2000L const val MAX_REVALIDATE_RETRIES = 3 - - // Campus networks come in two shapes: the hostel/block form - // "-VIT" (optionally with a trailing band suffix Windows - // appends, e.g. "G-VIT 5") and the academic-block form "VIT" - // ("VIT5G", "VIT2.4G"). Anchored to the start of the SSID so an - // unrelated network that merely contains "VIT" somewhere in its name - // does not match. - val VIT_SSID_PATTERN = Regex("^(?:[A-Za-z]-)?VIT", RegexOption.IGNORE_CASE) } private val logger = platform.logger @@ -125,7 +120,7 @@ class LatchEngine( override suspend fun submitAndAwait(command: LatchCommand, timeoutMs: Long): Boolean { val done = CompletableDeferred() - commands.trySend(QueuedCommand(command, done)) + if (commands.trySend(QueuedCommand(command, done)).isFailure) return false return withTimeoutOrNull(timeoutMs) { done.await() } != null } @@ -215,7 +210,10 @@ class LatchEngine( activeActionJob?.cancel() activeActionJob = null healthCheckJob?.cancel() - unlatch() + _isLatched.value = false + sessions.stopSessionAndAwait() + commands.close() + scope.coroutineContext.cancelChildren() } } } @@ -371,17 +369,6 @@ class LatchEngine( resolves } - /** - * True unless the SSID is readable and readably *not* a campus network. - */ - private fun isVitCampusSsid(ssid: String?): Boolean { - val clean = ssid?.trim()?.removeSurrounding("\"")?.takeIf { it.isNotEmpty() } ?: return true - return VIT_SSID_PATTERN.containsMatchIn(clean) || - clean.contains("VIT", ignoreCase = true) || - clean.endsWith("-VIT", ignoreCase = true) || - SettingsManager.allowedSsids.value.any { clean.contains(it, ignoreCase = true) } - } - private suspend fun handleCaptivePortal(handle: NetworkHandle) { logger.d(TAG, "[ConnectAnalysis] Step 4/4: Authenticating with Captive Portal...") ConnectionStatusManager.postStatus( @@ -445,7 +432,18 @@ class LatchEngine( ConnectionStatus.Failed(ConnectionStatus.Reason.Disconnected) ) - if (handle != null && wasLatched) { + // This process's memory is not the only way to be logged in: a CLI + // one-shot creates the engine for the length of a single command, so + // _isLatched is false even when the portal is authenticated, and + // `latch-cli --logout` with no daemon running went through the motions + // without ever telling the portal. Asking the network settles it. + // Deliberately after the status post -- the probe must not delay the + // "Disconnected" the user is waiting to see -- and only when this + // process does not already know, so the common path is unchanged. + val authenticated = wasLatched || + probeCampusNetwork(platform.wifi, platform.httpTransport, logger).latched + + if (handle != null && authenticated) { platform.wifi.bindProcess(handle) try { val ok = withTimeoutOrNull(2000L) { diff --git a/core/src/commonMain/kotlin/com/vinnovateit/latch/core/platform/KeyValueStore.kt b/core/src/commonMain/kotlin/com/vinnovateit/latch/core/platform/KeyValueStore.kt index 1ca5449c..be3f4439 100644 --- a/core/src/commonMain/kotlin/com/vinnovateit/latch/core/platform/KeyValueStore.kt +++ b/core/src/commonMain/kotlin/com/vinnovateit/latch/core/platform/KeyValueStore.kt @@ -13,6 +13,15 @@ interface KeyValueStore { fun putString(key: String, value: String) fun putBoolean(key: String, value: Boolean) fun putStringSet(key: String, value: Set) + + /** + * Makes every write so far durable before the process may exit. + * + * A store that writes synchronously has nothing to do here. One that defers + * writes must land them now: a `latch-cli --settings set ...` process exits + * within milliseconds of the write, far sooner than any background writer. + */ + fun flush() {} } /** Used before a real store is installed, and in tests. */ diff --git a/core/src/commonMain/kotlin/com/vinnovateit/latch/core/wifi/CampusNetworkProbe.kt b/core/src/commonMain/kotlin/com/vinnovateit/latch/core/wifi/CampusNetworkProbe.kt new file mode 100644 index 00000000..2088eb18 --- /dev/null +++ b/core/src/commonMain/kotlin/com/vinnovateit/latch/core/wifi/CampusNetworkProbe.kt @@ -0,0 +1,48 @@ +package com.vinnovateit.latch.core.wifi + +import com.vinnovateit.latch.core.platform.HttpTransport +import com.vinnovateit.latch.core.platform.Logger +import com.vinnovateit.latch.core.platform.WifiPlatform +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +/** Matches the engine's own portal-probe budget in checkAndAct. */ +private const val PROBE_TIMEOUT_MS = 3_500L + +private const val NO_CONTENT = 204 + +/** What the network says about itself right now, independent of any engine state. */ +data class CampusNetworkState( + val connected: Boolean, + val online: Boolean, + val ssid: String?, +) { + /** + * Latch's own definition of latched: real internet on a campus network, + * i.e. someone has already cleared the portal. Real internet on a cafe + * network is online but not latched. + */ + val latched: Boolean get() = online && isVitCampusSsid(ssid) +} + +/** + * Asks the network, rather than this process's memory, whether the portal has + * been cleared. + * + * Read-only by construction: it starts no session, posts no status and attempts + * no login, so it is safe on paths that must not change state -- a `--status` + * query, or deciding whether a logout has anything to log out of. + */ +suspend fun probeCampusNetwork( + wifi: WifiPlatform, + transport: HttpTransport, + logger: Logger, +): CampusNetworkState = withContext(Dispatchers.IO) { + val ssid = wifi.currentSsid() + if (!wifi.isConnectedToWifi()) return@withContext CampusNetworkState(false, false, ssid) + + val detector = CaptivePortalDetector(transport, logger) + val code = withTimeoutOrNull(PROBE_TIMEOUT_MS) { detector.checkPortalStatus(wifi.activeHandle()) } ?: -1 + CampusNetworkState(connected = true, online = code == NO_CONTENT, ssid = ssid) +} diff --git a/core/src/commonMain/kotlin/com/vinnovateit/latch/core/wifi/CampusSsid.kt b/core/src/commonMain/kotlin/com/vinnovateit/latch/core/wifi/CampusSsid.kt new file mode 100644 index 00000000..d71ce37b --- /dev/null +++ b/core/src/commonMain/kotlin/com/vinnovateit/latch/core/wifi/CampusSsid.kt @@ -0,0 +1,25 @@ +package com.vinnovateit.latch.core.wifi + +import com.vinnovateit.latch.core.settings.SettingsManager + +// Campus networks come in two shapes: the hostel/block form +// "-VIT" (optionally with a trailing band suffix Windows +// appends, e.g. "G-VIT 5") and the academic-block form "VIT" +// ("VIT5G", "VIT2.4G"). Anchored to the start of the SSID so an +// unrelated network that merely contains "VIT" somewhere in its name +// does not match. +private val VIT_SSID_PATTERN = Regex("^(?:[A-Za-z]-)?VIT", RegexOption.IGNORE_CASE) + +/** + * True unless the SSID is readable and readably *not* a campus network. + * + * Shared by the engine's login gate and by the read-only probe a CLI one-shot + * uses when it owns the runtime, so both decide "campus network" identically. + */ +fun isVitCampusSsid(ssid: String?): Boolean { + val clean = ssid?.trim()?.removeSurrounding("\"")?.takeIf { it.isNotEmpty() } ?: return true + return VIT_SSID_PATTERN.containsMatchIn(clean) || + clean.contains("VIT", ignoreCase = true) || + clean.endsWith("-VIT", ignoreCase = true) || + SettingsManager.allowedSsids.value.any { clean.contains(it, ignoreCase = true) } +} diff --git a/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/LatchCore.kt b/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/LatchCore.kt new file mode 100644 index 00000000..4eb3191d --- /dev/null +++ b/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/LatchCore.kt @@ -0,0 +1,16 @@ +package com.vinnovateit.latch.core + +import java.util.Properties + +object LatchCore { + val VERSION: String by lazy { + val properties = Properties() + val resource = checkNotNull(LatchCore::class.java.getResourceAsStream("/latch-version.properties")) { + "Missing latch-version.properties" + } + resource.use(properties::load) + checkNotNull(properties.getProperty("version")?.takeIf(String::isNotBlank)) { + "Missing Latch version" + } + } +} diff --git a/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/DesktopEngineRuntime.kt b/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/DesktopEngineRuntime.kt new file mode 100644 index 00000000..42fe77b2 --- /dev/null +++ b/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/DesktopEngineRuntime.kt @@ -0,0 +1,60 @@ +package com.vinnovateit.latch.core.runtime + +import com.vinnovateit.latch.core.data.LatchDatabase +import com.vinnovateit.latch.core.data.buildDatabase +import com.vinnovateit.latch.core.domain.SessionRepository +import com.vinnovateit.latch.core.engine.LatchCommand +import com.vinnovateit.latch.core.engine.LatchEngine +import com.vinnovateit.latch.core.platform.Platform +import com.vinnovateit.latch.core.platform.UserNotifier +import com.vinnovateit.latch.core.settings.SettingsManager +import com.vinnovateit.latch.core.stats.ThroughputMonitor +import com.vinnovateit.latch.desktop.platform.DesktopPlatformServices +import java.util.concurrent.atomic.AtomicBoolean + +private const val ENGINE_SHUTDOWN_TIMEOUT_MS = 5_000L + +class DesktopEngineRuntime private constructor( + val platform: DesktopPlatformServices, + val database: LatchDatabase, + val sessions: SessionRepository, + val engine: LatchEngine, +) { + private val started = AtomicBoolean(false) + private val closed = AtomicBoolean(false) + val isClosed: Boolean get() = closed.get() + + fun start() { + check(!closed.get()) { "Runtime is closed." } + if (started.compareAndSet(false, true)) engine.start() + } + + suspend fun close() { + if (!closed.compareAndSet(false, true)) return + if (started.get()) engine.submitAndAwait(LatchCommand.Shutdown, ENGINE_SHUTDOWN_TIMEOUT_MS) + // Before the process can exit: a one-shot CLI writes a setting and is + // gone milliseconds later, well before a deferred write would land. + platform.settingsStore.flush() + database.close() + } + + companion object { + suspend fun create( + notifier: UserNotifier, + echoLogsToStdout: Boolean, + ): DesktopEngineRuntime { + val platform = DesktopPlatformServices(echoLogsToStdout, notifier) + Platform.install(platform) + SettingsManager.initialize(platform.settingsStore) + val database = buildDatabase() + val sessions = SessionRepository(database.statsDao(), ThroughputMonitor(platform.counters)) + sessions.initialize() + return DesktopEngineRuntime( + platform = platform, + database = database, + sessions = sessions, + engine = LatchEngine(platform, sessions), + ) + } + } +} diff --git a/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/DesktopOwnership.kt b/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/DesktopOwnership.kt new file mode 100644 index 00000000..5e7af933 --- /dev/null +++ b/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/DesktopOwnership.kt @@ -0,0 +1,47 @@ +package com.vinnovateit.latch.core.runtime + +import java.io.File +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay + +sealed interface DesktopOwnership { + data class Owner(val coordinator: InstanceCoordinator) : DesktopOwnership + data object ActivatedExisting : DesktopOwnership + data class Failure(val message: String) : DesktopOwnership +} + +suspend fun claimDesktopOwnership( + dataDir: File, + timeoutMillis: Long = 10_000, + retryDelayMillis: Long = 100, + handler: suspend (InstanceRequest) -> InstanceResponse, +): DesktopOwnership { + val deadline = System.nanoTime() + timeoutMillis.milliseconds.inWholeNanoseconds + var takeoverRequested = false + var lastFailure = "Timed out waiting for the active Latch instance." + + while (System.nanoTime() <= deadline) { + when (val acquired = InstanceCoordinator.tryAcquire(dataDir, OwnerKind.DESKTOP, handler)) { + is AcquireResult.Owner -> return DesktopOwnership.Owner(acquired.coordinator) + is AcquireResult.Failure -> lastFailure = acquired.message + is AcquireResult.Existing -> when (acquired.metadata.ownerKind) { + OwnerKind.DESKTOP -> { + val response = acquired.client.send(RuntimeCommand.ACTIVATE_UI) + return if (response.ok) DesktopOwnership.ActivatedExisting + else DesktopOwnership.Failure(response.message.ifBlank { response.code }) + } + OwnerKind.CLI_DAEMON -> if (!takeoverRequested) { + val response = acquired.client.send(RuntimeCommand.TAKE_OVER) + if (!response.ok) { + return DesktopOwnership.Failure(response.message.ifBlank { response.code }) + } + takeoverRequested = true + } + OwnerKind.CLI_ONESHOT -> Unit + } + } + delay(retryDelayMillis) + } + + return DesktopOwnership.Failure(lastFailure) +} diff --git a/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/InstanceCoordinator.kt b/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/InstanceCoordinator.kt new file mode 100644 index 00000000..ef762b11 --- /dev/null +++ b/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/InstanceCoordinator.kt @@ -0,0 +1,227 @@ +package com.vinnovateit.latch.core.runtime + +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.RandomAccessFile +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.nio.channels.FileChannel +import java.nio.channels.FileLock +import java.security.MessageDigest +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.thread +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json + +private const val CONNECT_TIMEOUT_MS = 2_000 +private const val REQUEST_READ_TIMEOUT_MS = 2_000 +private const val RESPONSE_READ_TIMEOUT_MS = 25_000 + +sealed interface AcquireResult { + data class Owner(val coordinator: InstanceCoordinator) : AcquireResult + data class Existing(val client: InstanceClient, val metadata: OwnerMetadata) : AcquireResult + data class Failure(val message: String) : AcquireResult +} + +class InstanceCoordinator private constructor( + private val files: SecureRuntimeFiles, + private val channel: FileChannel, + private val lock: FileLock, + private val server: ServerSocket, + private val token: String, + private val handler: suspend (InstanceRequest) -> InstanceResponse, +) : AutoCloseable { + val port: Int get() = server.localPort + private val closed = AtomicBoolean(false) + private val listener = thread(start = false, isDaemon = true, name = "LatchRuntimeListener") { + listen() + } + + private fun start() { + listener.start() + } + + private fun listen() { + while (!server.isClosed) { + val socket = try { + server.accept() + } catch (_: Exception) { + break + } + handle(socket) + } + } + + private fun handle(socket: Socket) { + socket.use { client -> + client.soTimeout = REQUEST_READ_TIMEOUT_MS + val response = try { + when (val payload = readPayload(client)) { + is PayloadResult.TooLarge -> failure("", "PAYLOAD_TOO_LARGE", "Request exceeds 64 KiB.") + is PayloadResult.Value -> process(payload.text) + } + } catch (_: Exception) { + failure("", "MALFORMED_REQUEST", "Unable to read request.") + } + runCatching { + client.getOutputStream().bufferedWriter(Charsets.UTF_8).use { writer -> + writer.write(JSON.encodeToString(response)) + writer.newLine() + } + } + } + } + + private fun process(payload: String): InstanceResponse { + val request = runCatching { JSON.decodeFromString(payload) }.getOrNull() + ?: return failure("", "MALFORMED_REQUEST", "Request is not valid protocol JSON.") + if (request.version != INSTANCE_PROTOCOL_VERSION) { + return failure(request.requestId, "PROTOCOL_MISMATCH", "Unsupported protocol version.") + } + if (!constantTimeEquals(token, request.token)) { + return failure(request.requestId, "UNAUTHORIZED", "Authentication failed.") + } + if (request.requestId.isBlank()) { + return failure("", "MALFORMED_REQUEST", "requestId is required.") + } + return try { + runBlocking { handler(request) } + } catch (_: Exception) { + failure(request.requestId, "INTERNAL_ERROR", "Owner could not process the request.") + } + } + + override fun close() { + if (!closed.compareAndSet(false, true)) return + runCatching { server.close() } + files.clearOwnerState() + runCatching { lock.release() } + runCatching { channel.close() } + } + + companion object { + fun tryAcquire( + dataDir: File, + ownerKind: OwnerKind, + handler: suspend (InstanceRequest) -> InstanceResponse, + ): AcquireResult { + val files = SecureRuntimeFiles(dataDir) + val channel = runCatching { RandomAccessFile(files.lockFile, "rw").channel } + .getOrElse { return AcquireResult.Failure("Unable to open runtime lock: ${it.message}") } + val lock = runCatching { channel.tryLock() }.getOrNull() + if (lock == null) { + runCatching { channel.close() } + return existingOwner(files) + } + + return try { + val token = files.createToken() + val server = ServerSocket(0, 50, InetAddress.getByName("127.0.0.1")) + val metadata = OwnerMetadata( + version = INSTANCE_PROTOCOL_VERSION, + ownerKind = ownerKind, + port = server.localPort, + pid = ProcessHandle.current().pid(), + startedAt = System.currentTimeMillis(), + ) + files.writeMetadata(metadata) + val coordinator = InstanceCoordinator(files, channel, lock, server, token, handler) + coordinator.start() + AcquireResult.Owner(coordinator) + } catch (error: Exception) { + files.clearOwnerState() + runCatching { lock.release() } + runCatching { channel.close() } + AcquireResult.Failure("Unable to start runtime owner: ${error.message}") + } + } + + private fun existingOwner(files: SecureRuntimeFiles): AcquireResult { + val metadata = files.readMetadata() + ?: return AcquireResult.Failure("Runtime lock is held but owner metadata is unavailable.") + val token = files.readToken() + ?: return AcquireResult.Failure("Runtime lock is held but authentication token is unavailable.") + if (metadata.version != INSTANCE_PROTOCOL_VERSION) { + return AcquireResult.Failure("The running Latch instance uses an incompatible protocol.") + } + val alive = runCatching { + ProcessHandle.of(metadata.pid).map(ProcessHandle::isAlive).orElse(false) + }.getOrDefault(false) + if (!alive) return AcquireResult.Failure("Runtime owner is no longer alive.") + return AcquireResult.Existing(InstanceClient(metadata.port, token), metadata) + } + } +} + +class InstanceClient( + private val port: Int, + private val token: String, +) { + suspend fun send( + command: RuntimeCommand, + arguments: Map = emptyMap(), + ): InstanceResponse { + val request = InstanceRequest( + version = INSTANCE_PROTOCOL_VERSION, + token = token, + requestId = UUID.randomUUID().toString(), + command = command, + arguments = arguments, + ) + return sendRaw(JSON.encodeToString(request)) + } + + suspend fun sendRaw(payload: String): InstanceResponse = withContext(Dispatchers.IO) { + val requestId = runCatching { + JSON.decodeFromString(payload).requestId + }.getOrDefault("") + try { + Socket().use { socket -> + socket.connect(InetSocketAddress(InetAddress.getByName("127.0.0.1"), port), CONNECT_TIMEOUT_MS) + socket.soTimeout = RESPONSE_READ_TIMEOUT_MS + socket.getOutputStream().apply { + write(payload.toByteArray(Charsets.UTF_8)) + write('\n'.code) + flush() + } + val line = socket.getInputStream().bufferedReader(Charsets.UTF_8).readLine() + ?: return@withContext failure(requestId, "OWNER_UNAVAILABLE", "Owner closed the connection.") + JSON.decodeFromString(line) + } + } catch (error: Exception) { + failure(requestId, "OWNER_UNAVAILABLE", error.message ?: "Unable to contact owner.") + } + } +} + +private sealed interface PayloadResult { + data class Value(val text: String) : PayloadResult + data object TooLarge : PayloadResult +} + +private fun readPayload(socket: Socket): PayloadResult { + val output = ByteArrayOutputStream() + while (true) { + val next = socket.getInputStream().read() + if (next == -1 || next == '\n'.code) break + if (output.size() >= MAX_INSTANCE_REQUEST_BYTES) return PayloadResult.TooLarge + output.write(next) + } + return PayloadResult.Value(output.toString(Charsets.UTF_8)) +} + +private fun constantTimeEquals(expected: String, actual: String): Boolean = + MessageDigest.isEqual(expected.toByteArray(Charsets.UTF_8), actual.toByteArray(Charsets.UTF_8)) + +private fun failure(requestId: String, code: String, message: String): InstanceResponse = + InstanceResponse(requestId = requestId, ok = false, code = code, message = message) + +private val JSON = Json { + encodeDefaults = true + ignoreUnknownKeys = false +} diff --git a/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/InstanceProtocol.kt b/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/InstanceProtocol.kt new file mode 100644 index 00000000..128fa3a0 --- /dev/null +++ b/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/InstanceProtocol.kt @@ -0,0 +1,52 @@ +package com.vinnovateit.latch.core.runtime + +import kotlinx.serialization.Serializable + +const val INSTANCE_PROTOCOL_VERSION = 2 +const val MAX_INSTANCE_REQUEST_BYTES = 64 * 1024 + +@Serializable +enum class OwnerKind { DESKTOP, CLI_DAEMON, CLI_ONESHOT } + +@Serializable +enum class RuntimeCommand { + PING, + ACTIVATE_UI, + TAKE_OVER, + DEACTIVATE, + SETUP_STATUS, + STATUS, + LOGIN, + LOGOUT, + HISTORY, + GET_SETTINGS, + SET_SETTING, + SET_CREDENTIALS, +} + +@Serializable +data class InstanceRequest( + val version: Int, + val token: String, + val requestId: String, + val command: RuntimeCommand, + val arguments: Map = emptyMap(), +) + +@Serializable +data class InstanceResponse( + val requestId: String, + val ok: Boolean, + val code: String, + val message: String = "", + val data: Map = emptyMap(), +) + +@Serializable +data class OwnerMetadata( + val version: Int, + val ownerKind: OwnerKind, + val port: Int, + val pid: Long, + val startedAt: Long, +) diff --git a/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/LiveNetworkProbe.kt b/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/LiveNetworkProbe.kt new file mode 100644 index 00000000..0a00797e --- /dev/null +++ b/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/LiveNetworkProbe.kt @@ -0,0 +1,33 @@ +package com.vinnovateit.latch.core.runtime + +import com.vinnovateit.latch.core.platform.HttpTransport +import com.vinnovateit.latch.core.platform.Logger +import com.vinnovateit.latch.core.platform.WifiPlatform +import com.vinnovateit.latch.core.wifi.probeCampusNetwork + +/** + * Reads the live network state without touching the engine. + * + * A CLI one-shot owns the runtime only for the length of one command, so its + * engine is created cold: `isLatched` is still at its initial `false` and no + * status has been posted yet. Reporting that as the answer to `--status` said + * "latched: no" on a machine that was, in fact, latched -- by the desktop app, + * by a previous session, or by anything else that had already cleared the + * portal. + */ +suspend fun probeRuntimeSnapshot( + wifi: WifiPlatform, + transport: HttpTransport, + logger: Logger, +): RuntimeSnapshot { + val state = probeCampusNetwork(wifi, transport, logger) + return RuntimeSnapshot( + connection = when { + !state.connected -> "disconnected" + state.online -> "online" + else -> "connected" + }, + ssid = state.ssid, + latched = state.latched, + ) +} diff --git a/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/RuntimeCommandService.kt b/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/RuntimeCommandService.kt new file mode 100644 index 00000000..b4d95aaa --- /dev/null +++ b/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/RuntimeCommandService.kt @@ -0,0 +1,258 @@ +package com.vinnovateit.latch.core.runtime + +import com.vinnovateit.latch.core.engine.LatchCommand +import com.vinnovateit.latch.core.settings.SettingsManager +import com.vinnovateit.latch.core.wifi.ConnectionStatus +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +private const val RUNTIME_COMMAND_TIMEOUT_MS = 20_000L + +data class RuntimeSnapshot(val connection: String, val ssid: String?, val latched: Boolean) + +@Serializable +data class RuntimeSessionRecord( + val start: Long, + val end: Long, + val rx: Long, + val tx: Long, + val maxRx: Long, + val maxTx: Long, +) + +data class RuntimeSettingsSnapshot(val autoLogin: Boolean, val allowedSsids: Set) + +data class RuntimeOperation( + val ok: Boolean, + val code: String = if (ok) "OK" else "INTERNAL_ERROR", + val message: String = "", +) + +interface RuntimeCommandTarget { + suspend fun isSetup(): Boolean + suspend fun snapshot(): RuntimeSnapshot + suspend fun login(): RuntimeOperation + suspend fun logout(): RuntimeOperation + suspend fun history(): List + suspend fun settings(): RuntimeSettingsSnapshot + suspend fun setAutoLogin(enabled: Boolean) + suspend fun setAllowedSsids(values: Set) + suspend fun setCredentials(userId: String, password: String) +} + +class RuntimeCommandService( + private val ownerKind: OwnerKind, + private val target: RuntimeCommandTarget, + private val onActivateUi: () -> Unit = {}, + private val onTakeOver: suspend () -> Boolean = { false }, + private val onDeactivate: suspend () -> Boolean = { false }, +) { + constructor( + ownerKind: OwnerKind, + runtime: DesktopEngineRuntime, + onActivateUi: () -> Unit = {}, + onTakeOver: suspend () -> Boolean = { false }, + onDeactivate: suspend () -> Boolean = { false }, + ) : this(ownerKind, runtimeTarget(ownerKind, runtime), onActivateUi, onTakeOver, onDeactivate) + + suspend fun execute(request: InstanceRequest): InstanceResponse { + if (request.version != INSTANCE_PROTOCOL_VERSION) { + return failure(request, "PROTOCOL_MISMATCH", "Unsupported protocol version.") + } + + return try { + when (request.command) { + RuntimeCommand.PING -> success(request, mapOf("owner" to ownerKind.wireName())) + RuntimeCommand.ACTIVATE_UI -> { + onActivateUi() + success(request) + } + RuntimeCommand.TAKE_OVER -> { + if (ownerKind == OwnerKind.CLI_DAEMON && onTakeOver()) success(request) + else failure(request, "OWNER_CHANGED", "The active owner refused takeover.") + } + RuntimeCommand.DEACTIVATE -> { + if (ownerKind == OwnerKind.CLI_DAEMON && onDeactivate()) success(request) + else failure(request, "OWNER_CHANGED", "The active owner is not the CLI daemon.") + } + RuntimeCommand.SETUP_STATUS -> success( + request, + mapOf("configured" to target.isSetup().toString()), + ) + RuntimeCommand.STATUS -> status(request) + RuntimeCommand.LOGIN -> operation(request, target.login()) + RuntimeCommand.LOGOUT -> operation(request, target.logout()) + RuntimeCommand.HISTORY -> success( + request, + mapOf("sessions" to JSON.encodeToString(target.history())), + ) + RuntimeCommand.GET_SETTINGS -> { + val settings = target.settings() + success( + request, + mapOf( + "autoLogin" to settings.autoLogin.toString(), + "allowedSsids" to JSON.encodeToString(settings.allowedSsids.sorted()), + ), + ) + } + RuntimeCommand.SET_SETTING -> setSetting(request) + RuntimeCommand.SET_CREDENTIALS -> setCredentials(request) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + failure(request, "INTERNAL_ERROR", "The owner could not complete the operation.") + } + } + + private suspend fun status(request: InstanceRequest): InstanceResponse { + val snapshot = target.snapshot() + return success( + request, + mapOf( + "owner" to ownerKind.wireName(), + "connection" to snapshot.connection, + "ssid" to snapshot.ssid.orEmpty(), + "latched" to snapshot.latched.toString(), + ), + ) + } + + private suspend fun setSetting(request: InstanceRequest): InstanceResponse { + val key = request.arguments["key"] + ?: return failure(request, "INVALID_ARGUMENT", "Setting key is required.") + val value = request.arguments["value"] + ?: return failure(request, "INVALID_ARGUMENT", "Setting value is required.") + when (key) { + "auto-login" -> { + val enabled = when (value) { + "on" -> true + "off" -> false + else -> return failure(request, "INVALID_ARGUMENT", "auto-login must be on or off.") + } + target.setAutoLogin(enabled) + } + "allowed-ssids" -> { + val values = value.split(',').map(String::trim) + if (values.isEmpty() || values.any(String::isEmpty)) { + return failure(request, "INVALID_ARGUMENT", "allowed-ssids contains an empty entry.") + } + target.setAllowedSsids(values.toSet()) + } + else -> return failure(request, "INVALID_ARGUMENT", "Unknown setting: $key") + } + return success(request) + } + + private suspend fun setCredentials(request: InstanceRequest): InstanceResponse { + val userId = request.arguments["userId"]?.trim().orEmpty() + val password = request.arguments["password"].orEmpty() + if (userId.isEmpty() || password.isEmpty()) { + return failure(request, "INVALID_ARGUMENT", "Both user ID and password are required.") + } + target.setCredentials(userId, password) + return success(request) + } + + private fun operation(request: InstanceRequest, result: RuntimeOperation): InstanceResponse = + if (result.ok) success(request) else failure(request, result.code, result.message) +} + +/** + * A desktop or CLI-daemon owner keeps its engine running and so answers from it. + * A CLI one-shot creates its engine for the length of a single command, leaving + * it cold -- it must read the network itself rather than report that cold state. + */ +private fun runtimeTarget(ownerKind: OwnerKind, runtime: DesktopEngineRuntime): RuntimeCommandTarget { + val target = DesktopRuntimeTarget(runtime) + return if (ownerKind == OwnerKind.CLI_ONESHOT) ProbedSnapshotTarget(target, runtime) else target +} + +private class ProbedSnapshotTarget( + delegate: RuntimeCommandTarget, + private val runtime: DesktopEngineRuntime, +) : RuntimeCommandTarget by delegate { + override suspend fun snapshot(): RuntimeSnapshot = probeRuntimeSnapshot( + wifi = runtime.platform.wifi, + transport = runtime.platform.httpTransport, + logger = runtime.platform.logger, + ) +} + +private class DesktopRuntimeTarget(private val runtime: DesktopEngineRuntime) : RuntimeCommandTarget { + override suspend fun isSetup(): Boolean = runtime.platform.credentials.exists() + + override suspend fun snapshot(): RuntimeSnapshot { + val status = runtime.engine.status.value + val connection = when (status) { + ConnectionStatus.Idle -> if (runtime.platform.wifi.isConnectedToWifi()) "connected" else "disconnected" + ConnectionStatus.Success -> "online" + is ConnectionStatus.Connecting -> "connecting:${status.step.name.toKebabCase()}" + is ConnectionStatus.Failed -> "failed:${status.reason.name.toKebabCase()}" + } + return RuntimeSnapshot(connection, runtime.platform.wifi.currentSsid(), runtime.engine.isLatched.value) + } + + override suspend fun login(): RuntimeOperation = execute(LatchCommand.CheckAndLogin, "Login") + + override suspend fun logout(): RuntimeOperation = execute(LatchCommand.Logout, "Logout") + + override suspend fun history(): List = + runtime.database.statsDao().getAllSessions().first().map { session -> + RuntimeSessionRecord( + session.startTime, + session.endTime, + session.rxBytes, + session.txBytes, + session.maxRxBps, + session.maxTxBps, + ) + } + + override suspend fun settings() = RuntimeSettingsSnapshot( + SettingsManager.autoLogin.value, + SettingsManager.allowedSsids.value, + ) + + override suspend fun setAutoLogin(enabled: Boolean) = SettingsManager.setAutoLogin(enabled) + + override suspend fun setAllowedSsids(values: Set) = SettingsManager.setAllowedSsids(values) + + override suspend fun setCredentials(userId: String, password: String) = + runtime.platform.credentials.save(userId, password) + + private suspend fun execute(command: LatchCommand, label: String): RuntimeOperation { + if (!runtime.engine.submitAndAwait(command, RUNTIME_COMMAND_TIMEOUT_MS)) { + return RuntimeOperation(false, "TIMEOUT", "$label timed out.") + } + val failed = runtime.engine.status.value as? ConnectionStatus.Failed ?: return RuntimeOperation(true) + val code = when (failed.reason) { + ConnectionStatus.Reason.NoCredentials -> "NO_CREDENTIALS" + ConnectionStatus.Reason.WifiOff, + ConnectionStatus.Reason.NotOnWifi, + ConnectionStatus.Reason.NotTargetNetwork, + ConnectionStatus.Reason.Disconnected -> "NO_WIFI" + else -> "INTERNAL_ERROR" + } + return RuntimeOperation(false, code, "$label failed: ${failed.reason.name.toKebabCase()}") + } +} + +private fun OwnerKind.wireName(): String = name.lowercase().replace('_', '-') + +private fun String.toKebabCase(): String = + fold(StringBuilder()) { result, character -> + if (character.isUpperCase() && result.isNotEmpty()) result.append('-') + result.append(character.lowercaseChar()) + }.toString() + +private fun success(request: InstanceRequest, data: Map = emptyMap()) = + InstanceResponse(request.requestId, ok = true, code = "OK", data = data) + +private fun failure(request: InstanceRequest, code: String, message: String) = + InstanceResponse(request.requestId, ok = false, code = code, message = message) + +private val JSON = Json { encodeDefaults = true } diff --git a/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/SecureRuntimeFiles.kt b/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/SecureRuntimeFiles.kt new file mode 100644 index 00000000..d97a5907 --- /dev/null +++ b/core/src/desktopMain/kotlin/com/vinnovateit/latch/core/runtime/SecureRuntimeFiles.kt @@ -0,0 +1,86 @@ +package com.vinnovateit.latch.core.runtime + +import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFilePermission +import java.security.SecureRandom +import java.util.Base64 +import kotlinx.serialization.json.Json + +class SecureRuntimeFiles(private val dataDir: File) { + val lockFile: File get() = dataDir.resolve(".runtime.lock") + val metadataFile: File get() = dataDir.resolve(".runtime.json") + val tokenFile: File get() = dataDir.resolve(".runtime.token") + + private val json = Json { ignoreUnknownKeys = false } + + init { + dataDir.mkdirs() + } + + fun createToken(): String { + val bytes = ByteArray(32).also(SecureRandom()::nextBytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes).also(::writeToken) + } + + fun writeToken(token: String) { + atomicWrite(tokenFile, token) + } + + fun readToken(): String? = runCatching { + tokenFile.readText(Charsets.UTF_8).trim().takeIf(String::isNotEmpty) + }.getOrNull() + + fun writeMetadata(metadata: OwnerMetadata) { + atomicWrite(metadataFile, json.encodeToString(metadata)) + } + + fun readMetadata(): OwnerMetadata? = runCatching { + json.decodeFromString(metadataFile.readText(Charsets.UTF_8)) + }.getOrNull() + + fun clearOwnerState() { + runCatching { metadataFile.delete() } + runCatching { tokenFile.delete() } + } + + private fun atomicWrite(destination: File, content: String) { + destination.parentFile?.mkdirs() + val temporary = Files.createTempFile(destination.parentFile.toPath(), destination.name, ".tmp") + try { + Files.writeString(temporary, content, Charsets.UTF_8) + restrictToOwner(temporary.toFile()) + try { + Files.move( + temporary, + destination.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary, destination.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + restrictToOwner(destination) + } finally { + Files.deleteIfExists(temporary) + } + } + + private fun restrictToOwner(file: File) { + val path = file.toPath() + val posix = runCatching { Files.getFileStore(path).supportsFileAttributeView("posix") }.getOrDefault(false) + if (posix) { + Files.setPosixFilePermissions( + path, + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + ) + } else { + file.setReadable(false, false) + file.setWritable(false, false) + check(file.setReadable(true, true)) { "Unable to make ${file.name} owner-readable" } + check(file.setWritable(true, true)) { "Unable to make ${file.name} owner-writable" } + } + } +} diff --git a/core/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/AppPaths.kt b/core/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/AppPaths.kt index ba05b3f4..dd81881d 100644 --- a/core/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/AppPaths.kt +++ b/core/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/AppPaths.kt @@ -18,21 +18,25 @@ object AppPaths { val isLinux: Boolean = !isWindows && !isMac - val dataDir: File by lazy { - val base = when { - isWindows -> System.getenv("LOCALAPPDATA") - ?: System.getProperty("user.home") + "\\AppData\\Local" - - isMac -> System.getProperty("user.home") + "/Library/Application Support" - - // Linux / other: honour XDG if set. - else -> System.getenv("XDG_DATA_HOME") - ?: (System.getProperty("user.home") + "/.local/share") + val dataDir: File + get() { + System.getProperty("latch.dataDir")?.takeIf(String::isNotBlank)?.let { override -> + return File(override).apply { mkdirs() } + } + val base = when { + isWindows -> System.getenv("LOCALAPPDATA") + ?: System.getProperty("user.home") + "\\AppData\\Local" + + isMac -> System.getProperty("user.home") + "/Library/Application Support" + + // Linux / other: honour XDG if set. + else -> System.getenv("XDG_DATA_HOME") + ?: (System.getProperty("user.home") + "/.local/share") + } + return File(base, "Latch").apply { mkdirs() } } - File(base, "Latch").apply { mkdirs() } - } - val logsDir: File by lazy { File(dataDir, "logs").apply { mkdirs() } } + val logsDir: File get() = File(dataDir, "logs").apply { mkdirs() } /** * Downloaded update MSIs. Deliberately not a temp file: the JVM exits @@ -40,7 +44,7 @@ object AppPaths { * shutdown would be racing the installer that still needs to read it. * Swept on startup instead -- see GithubUpdater.cleanStaleDownloads. */ - val updatesDir: File by lazy { File(dataDir, "updates").apply { mkdirs() } } + val updatesDir: File get() = File(dataDir, "updates").apply { mkdirs() } /** DPAPI-encrypted credential blob. */ val credentialsFile: File get() = File(dataDir, "credentials.bin") diff --git a/core/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/platform/DesktopPlatformServices.kt b/core/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/platform/DesktopPlatformServices.kt index 112ee4a8..1627322f 100644 --- a/core/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/platform/DesktopPlatformServices.kt +++ b/core/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/platform/DesktopPlatformServices.kt @@ -11,6 +11,7 @@ import com.vinnovateit.latch.core.platform.PlatformServices import com.vinnovateit.latch.core.platform.SystemActions import com.vinnovateit.latch.core.platform.UserNotifier import com.vinnovateit.latch.core.platform.WifiPlatform +import com.vinnovateit.latch.core.LatchCore import com.vinnovateit.latch.desktop.AppPaths import com.vinnovateit.latch.desktop.platform.linux.LinuxCredentialStore import com.vinnovateit.latch.desktop.platform.linux.LinuxSystemActions @@ -20,7 +21,7 @@ import com.vinnovateit.latch.desktop.platform.windows.WindowsSystemActions import com.vinnovateit.latch.desktop.platform.windows.WindowsWifiPlatform private object DesktopBuildInfo : BuildInfo { - override val versionName: String = "1.3.8" + override val versionName: String = LatchCore.VERSION override val isDebug: Boolean = System.getProperty("latch.debug") == "true" override val isInstalled: Boolean = InstalledBuild.isInstalled } diff --git a/core/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/platform/JsonKeyValueStore.kt b/core/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/platform/JsonKeyValueStore.kt index ae008364..bd00b84b 100644 --- a/core/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/platform/JsonKeyValueStore.kt +++ b/core/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/platform/JsonKeyValueStore.kt @@ -46,18 +46,25 @@ class JsonKeyValueStore( private val writeChannel = Channel(10) // Channel's buffer has size 10. + /** Serialises the two write paths -- the writer coroutine and [flush]. */ + private val writeLock = Any() + init { load() // Load file then launch writer coroutine. scope.launch { for (jsonObj in writeChannel) { - file.parentFile?.mkdirs() - file.writeText(json.encodeToString(JsonObject.serializer(), jsonObj)) + write(jsonObj) logger.d(TAG, "Saved settings to file.") } } } + private fun write(obj: JsonObject) = synchronized(writeLock) { + file.parentFile?.mkdirs() + file.writeText(json.encodeToString(JsonObject.serializer(), obj)) + } + private fun load() { if (!file.exists()) return try { @@ -87,21 +94,24 @@ class JsonKeyValueStore( } } - private fun persist() { - try { - val obj = buildJsonObject { - values.forEach { (key, value) -> - when (value) { - is JsonPrimitiveOrArray.Str -> put(key, JsonPrimitive(value.value)) - is JsonPrimitiveOrArray.Bool -> put(key, JsonPrimitive(value.value)) - is JsonPrimitiveOrArray.StrSet -> put( - key, - JsonArray(value.value.map { JsonPrimitive(it) }), - ) - } + private fun snapshot(): JsonObject = synchronized(writeLock) { + buildJsonObject { + values.forEach { (key, value) -> + when (value) { + is JsonPrimitiveOrArray.Str -> put(key, JsonPrimitive(value.value)) + is JsonPrimitiveOrArray.Bool -> put(key, JsonPrimitive(value.value)) + is JsonPrimitiveOrArray.StrSet -> put( + key, + JsonArray(value.value.map { JsonPrimitive(it) }), + ) } } - if (!writeChannel.trySend(obj).isSuccess) { + } + } + + private fun persist() { + try { + if (!writeChannel.trySend(snapshot()).isSuccess) { throw Exception("Write channel is currently full.") } } catch (e: Throwable) { @@ -109,6 +119,24 @@ class JsonKeyValueStore( } } + /** + * Writes the current settings on the calling thread. + * + * The writer coroutine above is right for the desktop app, which outlives + * any queued write by hours. It is wrong for a one-shot `latch-cli + * --settings set ...`, which returns success and exits before the + * coroutine is ever scheduled -- the setting was silently lost. Called from + * DesktopEngineRuntime.close(), so every owner lands its writes on the way + * out. + */ + override fun flush() { + try { + write(snapshot()) + } catch (e: Throwable) { + logger.e(TAG, "Failed to flush settings", e) + } + } + override fun getString(key: String, default: String): String = (values[key] as? JsonPrimitiveOrArray.Str)?.value ?: default @@ -119,14 +147,14 @@ class JsonKeyValueStore( (values[key] as? JsonPrimitiveOrArray.StrSet)?.value ?: default override fun putString(key: String, value: String) { - values[key] = JsonPrimitiveOrArray.Str(value); persist() + synchronized(writeLock) { values[key] = JsonPrimitiveOrArray.Str(value) }; persist() } override fun putBoolean(key: String, value: Boolean) { - values[key] = JsonPrimitiveOrArray.Bool(value); persist() + synchronized(writeLock) { values[key] = JsonPrimitiveOrArray.Bool(value) }; persist() } override fun putStringSet(key: String, value: Set) { - values[key] = JsonPrimitiveOrArray.StrSet(value); persist() + synchronized(writeLock) { values[key] = JsonPrimitiveOrArray.StrSet(value) }; persist() } } diff --git a/core/src/desktopMain/resources/latch-version.properties b/core/src/desktopMain/resources/latch-version.properties new file mode 100644 index 00000000..1ad95faa --- /dev/null +++ b/core/src/desktopMain/resources/latch-version.properties @@ -0,0 +1 @@ +version=${latchVersion} diff --git a/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/engine/LatchEngineLogoutTest.kt b/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/engine/LatchEngineLogoutTest.kt new file mode 100644 index 00000000..66fac703 --- /dev/null +++ b/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/engine/LatchEngineLogoutTest.kt @@ -0,0 +1,158 @@ +package com.vinnovateit.latch.core.engine + +import com.vinnovateit.latch.core.data.buildDatabase +import com.vinnovateit.latch.core.domain.SessionRepository +import com.vinnovateit.latch.core.platform.BuildInfo +import com.vinnovateit.latch.core.platform.ByteCounterSource +import com.vinnovateit.latch.core.platform.ByteCounts +import com.vinnovateit.latch.core.platform.CredentialStore +import com.vinnovateit.latch.core.platform.HttpTransport +import com.vinnovateit.latch.core.platform.InMemoryKeyValueStore +import com.vinnovateit.latch.core.platform.KeyValueStore +import com.vinnovateit.latch.core.platform.Logger +import com.vinnovateit.latch.core.platform.NetworkHandle +import com.vinnovateit.latch.core.platform.NoOpLogger +import com.vinnovateit.latch.core.platform.PlatformCapabilities +import com.vinnovateit.latch.core.platform.PlatformServices +import com.vinnovateit.latch.core.platform.SystemActions +import com.vinnovateit.latch.core.platform.UserNotifier +import com.vinnovateit.latch.core.platform.WifiEvent +import com.vinnovateit.latch.core.platform.WifiPlatform +import com.vinnovateit.latch.core.stats.ThroughputMonitor +import java.net.HttpURLConnection +import java.net.URL +import java.util.Collections +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.runBlocking + +/** + * A CLI one-shot creates the engine for the length of a single command, so it + * reaches logout with isLatched still false. The portal logout must still be + * sent, or `latch-cli --logout` silently does nothing whenever no daemon is + * running. + */ +class LatchEngineLogoutTest { + @Test + fun `logout reaches the portal on a cold engine that is actually latched`() = withEngine( + probeResponse = 204, + ) { engine, transport -> + assertTrue(engine.submitAndAwait(LatchCommand.Logout, 20_000)) + + assertEquals(false, engine.isLatched.value) + assertTrue( + transport.requested.any { it.contains("authlogout") }, + "portal logout was never sent; requests were ${transport.requested}", + ) + } + + @Test + fun `logout does not touch the portal when the network is not latched`() = withEngine( + probeResponse = 302, + ) { engine, transport -> + assertTrue(engine.submitAndAwait(LatchCommand.Logout, 20_000)) + + assertEquals( + emptyList(), + transport.requested.filter { it.contains("authlogout") }, + "logged out of a portal this machine was never authenticated with", + ) + } + + private fun withEngine( + probeResponse: Int, + block: suspend (LatchEngine, RecordingTransport) -> Unit, + ) = runBlocking { + val directory = createTempDirectory("latch-logout-").toFile() + val previous = System.getProperty("latch.dataDir") + System.setProperty("latch.dataDir", directory.absolutePath) + val database = buildDatabase() + val transport = RecordingTransport(probeResponse) + val engine = LatchEngine( + platform = FakePlatform(FakeWifi, transport), + sessions = SessionRepository(database.statsDao(), ThroughputMonitor(NoCounters)), + ) + try { + engine.start() + block(engine, transport) + } finally { + engine.submitAndAwait(LatchCommand.Shutdown, 5_000) + database.close() + if (previous == null) System.clearProperty("latch.dataDir") else System.setProperty("latch.dataDir", previous) + directory.deleteRecursively() + } + } +} + +/** Answers the portal probe without a socket, and records what was asked for. */ +private class RecordingTransport(private val probeResponse: Int) : HttpTransport { + val requested: MutableList = Collections.synchronizedList(mutableListOf()) + + override fun open(url: URL, handle: NetworkHandle?): HttpURLConnection { + requested += url.toString() + return object : HttpURLConnection(url) { + override fun connect() = Unit + override fun disconnect() = Unit + override fun usingProxy(): Boolean = false + override fun getResponseCode(): Int = + if (url.toString().contains("generate_204")) probeResponse else 200 + } + } +} + +private object FakeWifi : WifiPlatform { + override fun isWifiEnabled(): Boolean = true + override fun isConnectedToWifi(): Boolean = true + override fun currentSsid(): String = "G-VIT 5" + override fun gatewayIp(): String? = null + override fun activeHandle(): NetworkHandle = FakeHandle + override val events: Flow = emptyFlow() +} + +private object FakeHandle : NetworkHandle { + override val id: String = "wlan0" +} + +private object NoCounters : ByteCounterSource { + override fun sample(): ByteCounts? = null +} + +private class FakePlatform( + override val wifi: WifiPlatform, + override val httpTransport: HttpTransport, +) : PlatformServices { + override val logger: Logger = NoOpLogger + override val buildInfo: BuildInfo = object : BuildInfo { + override val versionName = "test" + override val isDebug = true + override val isInstalled = false + } + override val capabilities: PlatformCapabilities = object : PlatformCapabilities { + override val supportsDynamicColor = false + override val supportsAutostart = false + } + override val settingsStore: KeyValueStore = InMemoryKeyValueStore() + override val credentials: CredentialStore = object : CredentialStore { + override fun save(userId: String, password: String) = Unit + override fun userId(): String? = null + override fun password(): String? = null + override fun exists(): Boolean = false + override fun clear() = Unit + } + override val counters: ByteCounterSource = NoCounters + override val notifier: UserNotifier = object : UserNotifier { + override fun showOngoing(title: String, text: String) = Unit + override fun notifyTransient(title: String, text: String, isError: Boolean) = Unit + override fun hideOngoing() = Unit + } + override val systemActions: SystemActions = object : SystemActions { + override fun openWifiSettings() = Unit + override fun openUrl(url: String) = Unit + override fun setAutostart(enabled: Boolean) = Unit + override fun isAutostartEnabled(): Boolean = false + } +} diff --git a/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/DesktopEngineRuntimeTest.kt b/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/DesktopEngineRuntimeTest.kt new file mode 100644 index 00000000..c930f767 --- /dev/null +++ b/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/DesktopEngineRuntimeTest.kt @@ -0,0 +1,49 @@ +package com.vinnovateit.latch.core.runtime + +import com.vinnovateit.latch.core.data.Session +import com.vinnovateit.latch.core.engine.LatchCommand +import com.vinnovateit.latch.core.platform.UserNotifier +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +class DesktopEngineRuntimeTest { + @Test + fun `runtime builds one shared graph and closes idempotently`() = runBlocking { + val directory = createTempDirectory("latch-engine-").toFile() + val previous = System.getProperty("latch.dataDir") + try { + System.setProperty("latch.dataDir", directory.absolutePath) + val runtime = DesktopEngineRuntime.create(NoOpNotifier, echoLogsToStdout = false) + + runtime.start() + runtime.start() + runtime.database.statsDao().insertSession(Session(startTime = 1, endTime = 2, rxBytes = 3, txBytes = 4, maxRxBps = 5, maxTxBps = 6)) + + runtime.close() + runtime.close() + assertTrue(runtime.isClosed) + assertFalse(runtime.engine.submitAndAwait(LatchCommand.Shutdown, timeoutMs = 100)) + + val reopened = DesktopEngineRuntime.create(NoOpNotifier, echoLogsToStdout = false) + val rows = reopened.database.statsDao().getAllSessions().first() + assertEquals(1, rows.size) + assertEquals(3, rows.single().rxBytes) + reopened.close() + } finally { + if (previous == null) System.clearProperty("latch.dataDir") else System.setProperty("latch.dataDir", previous) + directory.deleteRecursively() + } + } + +} + +private object NoOpNotifier : UserNotifier { + override fun showOngoing(title: String, text: String) = Unit + override fun notifyTransient(title: String, text: String, isError: Boolean) = Unit + override fun hideOngoing() = Unit +} diff --git a/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/DesktopOwnershipTest.kt b/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/DesktopOwnershipTest.kt new file mode 100644 index 00000000..b57fbe8f --- /dev/null +++ b/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/DesktopOwnershipTest.kt @@ -0,0 +1,82 @@ +package com.vinnovateit.latch.core.runtime + +import kotlin.concurrent.thread +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class DesktopOwnershipTest { + @Test + fun `second desktop activates the existing desktop`() = runBlocking { + val directory = createTempDirectory("latch-desktop-existing-").toFile() + var activated = false + val owner = assertIs( + InstanceCoordinator.tryAcquire(directory, OwnerKind.DESKTOP) { request -> + if (request.command == RuntimeCommand.ACTIVATE_UI) activated = true + InstanceResponse(request.requestId, true, "OK") + }, + ) + try { + val claim = claimDesktopOwnership(directory, timeoutMillis = 500) { echo(it) } + + assertIs(claim) + assertTrue(activated) + } finally { + owner.coordinator.close() + directory.deleteRecursively() + } + } + + @Test + fun `desktop takes ownership from cli daemon`() = runBlocking { + val directory = createTempDirectory("latch-desktop-takeover-").toFile() + lateinit var cliOwner: InstanceCoordinator + val acquired = assertIs( + InstanceCoordinator.tryAcquire(directory, OwnerKind.CLI_DAEMON) { request -> + if (request.command == RuntimeCommand.TAKE_OVER) { + thread(isDaemon = true) { + Thread.sleep(20) + cliOwner.close() + } + InstanceResponse(request.requestId, true, "OK") + } else { + echo(request) + } + }, + ) + cliOwner = acquired.coordinator + try { + val claim = claimDesktopOwnership(directory, timeoutMillis = 1_000, retryDelayMillis = 10) { echo(it) } + + val desktop = assertIs(claim) + desktop.coordinator.close() + } finally { + cliOwner.close() + directory.deleteRecursively() + } + } + + @Test + fun `failed daemon takeover is reported`() = runBlocking { + val directory = createTempDirectory("latch-desktop-refused-").toFile() + val owner = assertIs( + InstanceCoordinator.tryAcquire(directory, OwnerKind.CLI_DAEMON) { request -> + InstanceResponse(request.requestId, false, "OWNER_CHANGED", "refused") + }, + ) + try { + val claim = claimDesktopOwnership(directory, timeoutMillis = 500) { echo(it) } + + val failure = assertIs(claim) + assertEquals("refused", failure.message) + } finally { + owner.coordinator.close() + directory.deleteRecursively() + } + } + + private fun echo(request: InstanceRequest) = InstanceResponse(request.requestId, true, "OK") +} diff --git a/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/InstanceCoordinatorTest.kt b/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/InstanceCoordinatorTest.kt new file mode 100644 index 00000000..0d991cd4 --- /dev/null +++ b/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/InstanceCoordinatorTest.kt @@ -0,0 +1,217 @@ +package com.vinnovateit.latch.core.runtime + +import com.vinnovateit.latch.desktop.AppPaths +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json + +class InstanceCoordinatorTest { + @Test + fun `protocol request and response round trip through json`() { + val request = InstanceRequest( + version = INSTANCE_PROTOCOL_VERSION, + token = "secret", + requestId = "request-1", + command = RuntimeCommand.SET_SETTING, + arguments = mapOf("key" to "auto-login", "value" to "on"), + ) + val response = InstanceResponse( + requestId = "request-1", + ok = true, + code = "OK", + data = mapOf("owner" to "desktop"), + ) + + assertEquals(request, Json.decodeFromString(Json.encodeToString(request))) + assertEquals(response, Json.decodeFromString(Json.encodeToString(response))) + } + + @Test + fun `app paths honor isolated data directory override`() { + val temporary = createTempDirectory("latch-paths-").toFile() + val previous = System.getProperty("latch.dataDir") + try { + System.setProperty("latch.dataDir", temporary.absolutePath) + assertEquals(temporary.canonicalFile, AppPaths.dataDir.canonicalFile) + } finally { + if (previous == null) System.clearProperty("latch.dataDir") else System.setProperty("latch.dataDir", previous) + temporary.deleteRecursively() + } + } + + @Test + fun `runtime token is random and owner only`() { + val directory = createTempDirectory("latch-token-").toFile() + try { + val files = SecureRuntimeFiles(directory) + val first = files.createToken() + val second = files.createToken() + + assertNotEquals(first, second) + assertTrue(first.length >= 40) + if (Files.getFileStore(files.tokenFile.toPath()).supportsFileAttributeView("posix")) { + assertEquals( + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + Files.getPosixFilePermissions(files.tokenFile.toPath()), + ) + } + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `exactly one coordinator owns a runtime directory`() { + val directory = createTempDirectory("latch-owner-").toFile() + val first = InstanceCoordinator.tryAcquire(directory, OwnerKind.CLI_DAEMON, ::echo) + try { + val second = InstanceCoordinator.tryAcquire(directory, OwnerKind.DESKTOP, ::echo) + assertIs(first) + assertIs(second) + } finally { + (first as? AcquireResult.Owner)?.coordinator?.close() + directory.deleteRecursively() + } + } + + @Test + fun `authenticated client reaches owner`() = runBlocking { + val directory = createTempDirectory("latch-auth-").toFile() + val acquired = assertIs( + InstanceCoordinator.tryAcquire(directory, OwnerKind.DESKTOP, ::echo), + ) + try { + val existing = assertIs( + InstanceCoordinator.tryAcquire(directory, OwnerKind.CLI_ONESHOT, ::echo), + ) + + val response = existing.client.send(RuntimeCommand.PING, mapOf("message" to "hello")) + + assertTrue(response.ok) + assertEquals("OK", response.code) + assertEquals("hello", response.data["message"]) + } finally { + acquired.coordinator.close() + directory.deleteRecursively() + } + } + + @Test + fun `client waits beyond the handshake timeout for a runtime response`() = runBlocking { + val directory = createTempDirectory("latch-slow-command-").toFile() + val acquired = assertIs( + InstanceCoordinator.tryAcquire(directory, OwnerKind.DESKTOP) { request -> + delay(2_100) + echo(request) + }, + ) + try { + val existing = assertIs( + InstanceCoordinator.tryAcquire(directory, OwnerKind.CLI_ONESHOT, ::echo), + ) + + val response = existing.client.send(RuntimeCommand.LOGIN) + + assertTrue(response.ok) + assertEquals("OK", response.code) + } finally { + acquired.coordinator.close() + directory.deleteRecursively() + } + } + + @Test + fun `wrong token fails closed`() = runBlocking { + val directory = createTempDirectory("latch-bad-token-").toFile() + val acquired = assertIs( + InstanceCoordinator.tryAcquire(directory, OwnerKind.DESKTOP, ::echo), + ) + try { + val response = InstanceClient(acquired.coordinator.port, "wrong-token") + .send(RuntimeCommand.PING) + + assertEquals(false, response.ok) + assertEquals("UNAUTHORIZED", response.code) + } finally { + acquired.coordinator.close() + directory.deleteRecursively() + } + } + + @Test + fun `malformed request receives a bounded error`() = runBlocking { + val directory = createTempDirectory("latch-malformed-").toFile() + val acquired = assertIs( + InstanceCoordinator.tryAcquire(directory, OwnerKind.DESKTOP, ::echo), + ) + try { + val response = InstanceClient(acquired.coordinator.port, "unused").sendRaw("not-json") + assertEquals(false, response.ok) + assertEquals("MALFORMED_REQUEST", response.code) + } finally { + acquired.coordinator.close() + directory.deleteRecursively() + } + } + + @Test + fun `oversized request is rejected before parsing`() = runBlocking { + val directory = createTempDirectory("latch-large-").toFile() + val acquired = assertIs( + InstanceCoordinator.tryAcquire(directory, OwnerKind.DESKTOP, ::echo), + ) + try { + val oversized = "x".repeat(MAX_INSTANCE_REQUEST_BYTES + 1) + val response = InstanceClient(acquired.coordinator.port, "unused").sendRaw(oversized) + assertEquals(false, response.ok) + assertEquals("PAYLOAD_TOO_LARGE", response.code) + } finally { + acquired.coordinator.close() + directory.deleteRecursively() + } + } + + @Test + fun `stale runtime metadata is replaced by a new owner`() { + val directory = createTempDirectory("latch-stale-").toFile() + val files = SecureRuntimeFiles(directory) + files.writeToken("stale-token") + files.writeMetadata( + OwnerMetadata( + version = INSTANCE_PROTOCOL_VERSION, + ownerKind = OwnerKind.CLI_DAEMON, + port = 1, + pid = Long.MAX_VALUE, + startedAt = 1, + ), + ) + + val acquired = assertIs( + InstanceCoordinator.tryAcquire(directory, OwnerKind.DESKTOP, ::echo), + ) + try { + val current = files.readMetadata() + assertEquals(OwnerKind.DESKTOP, current?.ownerKind) + assertEquals(ProcessHandle.current().pid(), current?.pid) + assertNotEquals("stale-token", files.readToken()) + } finally { + acquired.coordinator.close() + directory.deleteRecursively() + } + } + + private suspend fun echo(request: InstanceRequest): InstanceResponse = InstanceResponse( + requestId = request.requestId, + ok = true, + code = "OK", + data = request.arguments, + ) +} diff --git a/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/LiveNetworkProbeTest.kt b/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/LiveNetworkProbeTest.kt new file mode 100644 index 00000000..3e67bf77 --- /dev/null +++ b/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/LiveNetworkProbeTest.kt @@ -0,0 +1,90 @@ +package com.vinnovateit.latch.core.runtime + +import com.vinnovateit.latch.core.platform.HttpTransport +import com.vinnovateit.latch.core.platform.NetworkHandle +import com.vinnovateit.latch.core.platform.NoOpLogger +import com.vinnovateit.latch.core.platform.WifiEvent +import com.vinnovateit.latch.core.platform.WifiPlatform +import java.net.HttpURLConnection +import java.net.URL +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.runBlocking + +class LiveNetworkProbeTest { + @Test + fun `reports latched when a campus network already has internet`() = runBlocking { + val snapshot = probe(ssid = "G-VIT 5", responseCode = 204) + + assertEquals(RuntimeSnapshot("online", "G-VIT 5", true), snapshot) + } + + @Test + fun `reports not latched behind the captive portal`() = runBlocking { + val snapshot = probe(ssid = "G-VIT 5", responseCode = 302) + + assertEquals(RuntimeSnapshot("connected", "G-VIT 5", false), snapshot) + } + + @Test + fun `internet on a non-campus network is online but not latched`() = runBlocking { + val snapshot = probe(ssid = "Airport Free WiFi", responseCode = 204) + + assertEquals(RuntimeSnapshot("online", "Airport Free WiFi", false), snapshot) + } + + @Test + fun `does not probe when Wi-Fi is disconnected`() = runBlocking { + var probed = false + val snapshot = probeRuntimeSnapshot( + wifi = FakeWifi(ssid = null, connected = false), + transport = StubTransport(204) { probed = true }, + logger = NoOpLogger, + ) + + assertEquals(RuntimeSnapshot("disconnected", null, false), snapshot) + assertEquals(false, probed) + } + + private suspend fun probe(ssid: String?, responseCode: Int): RuntimeSnapshot = probeRuntimeSnapshot( + wifi = FakeWifi(ssid = ssid, connected = true), + transport = StubTransport(responseCode), + logger = NoOpLogger, + ) +} + +private class StubTransport( + private val code: Int, + private val onOpen: () -> Unit = {}, +) : HttpTransport { + override fun open(url: URL, handle: NetworkHandle?): HttpURLConnection { + onOpen() + return StubConnection(url, code) + } +} + +private class FakeWifi( + private val ssid: String?, + private val connected: Boolean, +) : WifiPlatform { + override fun isWifiEnabled(): Boolean = connected + override fun isConnectedToWifi(): Boolean = connected + override fun currentSsid(): String? = ssid + override fun gatewayIp(): String? = null + override fun activeHandle(): NetworkHandle? = if (connected) FakeHandle else null + override val events: Flow = emptyFlow() +} + +private object FakeHandle : NetworkHandle { + override val id: String = "wlan0" +} + +/** Answers the portal probe without a socket: no network in unit tests. */ +private class StubConnection(url: URL, private val code: Int) : HttpURLConnection(url) { + override fun connect() = Unit + override fun disconnect() = Unit + override fun usingProxy(): Boolean = false + override fun getResponseCode(): Int = code +} diff --git a/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/RuntimeCommandServiceTest.kt b/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/RuntimeCommandServiceTest.kt new file mode 100644 index 00000000..d3b464c6 --- /dev/null +++ b/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/RuntimeCommandServiceTest.kt @@ -0,0 +1,205 @@ +package com.vinnovateit.latch.core.runtime + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class RuntimeCommandServiceTest { + @Test + fun `ping identifies the active owner`() = runBlocking { + val response = service().execute(request(RuntimeCommand.PING)) + + assertTrue(response.ok) + assertEquals("OK", response.code) + assertEquals("cli-daemon", response.data["owner"]) + } + + @Test + fun `setup status reports whether credentials exist`() = runBlocking { + val configured = service(FakeRuntimeTarget(setup = true)).execute(request(RuntimeCommand.SETUP_STATUS)) + val unconfigured = service(FakeRuntimeTarget(setup = false)).execute(request(RuntimeCommand.SETUP_STATUS)) + + assertEquals("true", configured.data["configured"]) + assertEquals("false", unconfigured.data["configured"]) + } + + @Test + fun `deactivate stops only a cli daemon owner`() = runBlocking { + var stopped = false + val cliService = RuntimeCommandService( + ownerKind = OwnerKind.CLI_DAEMON, + target = FakeRuntimeTarget(), + onDeactivate = { stopped = true; true }, + ) + val desktopService = RuntimeCommandService( + ownerKind = OwnerKind.DESKTOP, + target = FakeRuntimeTarget(), + onDeactivate = { true }, + ) + + val cliResponse = cliService.execute(request(RuntimeCommand.DEACTIVATE)) + val desktopResponse = desktopService.execute(request(RuntimeCommand.DEACTIVATE)) + + assertTrue(cliResponse.ok) + assertTrue(stopped) + assertEquals("OWNER_CHANGED", desktopResponse.code) + } + + @Test + fun `status and history are serialized for clients`() = runBlocking { + val target = FakeRuntimeTarget( + snapshot = RuntimeSnapshot("connected", "VIT", true), + sessionValues = listOf(RuntimeSessionRecord(1, 2, 3, 4, 5, 6)), + ) + val service = service(target) + + val status = service.execute(request(RuntimeCommand.STATUS)) + val history = service.execute(request(RuntimeCommand.HISTORY)) + + assertEquals(mapOf("owner" to "cli-daemon", "connection" to "connected", "ssid" to "VIT", "latched" to "true"), status.data) + assertTrue(history.data.getValue("sessions").contains("\"start\":1")) + } + + @Test + fun `settings can be read and changed through allowlisted keys`() = runBlocking { + val target = FakeRuntimeTarget(settingsValue = RuntimeSettingsSnapshot(true, setOf("VIT"))) + val service = service(target) + + val settings = service.execute(request(RuntimeCommand.GET_SETTINGS)) + val autoLogin = service.execute( + request(RuntimeCommand.SET_SETTING, mapOf("key" to "auto-login", "value" to "off")), + ) + val ssids = service.execute( + request(RuntimeCommand.SET_SETTING, mapOf("key" to "allowed-ssids", "value" to "VIT,G-VIT")), + ) + + assertEquals("true", settings.data["autoLogin"]) + assertEquals("[\"VIT\"]", settings.data["allowedSsids"]) + assertTrue(autoLogin.ok) + assertEquals(false, target.autoLoginValue) + assertTrue(ssids.ok) + assertEquals(setOf("VIT", "G-VIT"), target.allowedSsidsValue) + } + + @Test + fun `unknown and malformed settings fail validation`() = runBlocking { + val service = service() + + val unknown = service.execute( + request(RuntimeCommand.SET_SETTING, mapOf("key" to "theme", "value" to "dark")), + ) + val invalidToggle = service.execute( + request(RuntimeCommand.SET_SETTING, mapOf("key" to "auto-login", "value" to "yes")), + ) + val emptySsid = service.execute( + request(RuntimeCommand.SET_SETTING, mapOf("key" to "allowed-ssids", "value" to "VIT,")), + ) + + assertEquals("INVALID_ARGUMENT", unknown.code) + assertEquals("INVALID_ARGUMENT", invalidToggle.code) + assertEquals("INVALID_ARGUMENT", emptySsid.code) + } + + @Test + fun `credentials require both fields and are never echoed`() = runBlocking { + val target = FakeRuntimeTarget() + val service = service(target) + + val response = service.execute( + request(RuntimeCommand.SET_CREDENTIALS, mapOf("userId" to "22BCE0001", "password" to "secret")), + ) + val invalid = service.execute( + request(RuntimeCommand.SET_CREDENTIALS, mapOf("userId" to "22BCE0001", "password" to "")), + ) + + assertTrue(response.ok) + assertEquals("22BCE0001", target.credentialUserId) + assertEquals("secret", target.credentialPassword) + assertFalse(response.toString().contains("secret")) + assertEquals("INVALID_ARGUMENT", invalid.code) + } + + @Test + fun `engine operation errors retain stable codes`() = runBlocking { + val target = FakeRuntimeTarget( + loginResult = RuntimeOperation(false, "NO_WIFI", "Wi-Fi is unavailable."), + logoutResult = RuntimeOperation(false, "TIMEOUT", "Logout timed out."), + ) + val service = service(target) + + val login = service.execute(request(RuntimeCommand.LOGIN)) + val logout = service.execute(request(RuntimeCommand.LOGOUT)) + + assertEquals("NO_WIFI", login.code) + assertEquals("TIMEOUT", logout.code) + } + + @Test + fun `activation and takeover invoke owner callbacks`() = runBlocking { + var activated = false + var takeover = false + val service = RuntimeCommandService( + ownerKind = OwnerKind.CLI_DAEMON, + target = FakeRuntimeTarget(), + onActivateUi = { activated = true }, + onTakeOver = { takeover = true; true }, + ) + + val activation = service.execute(request(RuntimeCommand.ACTIVATE_UI)) + val handoff = service.execute(request(RuntimeCommand.TAKE_OVER)) + + assertTrue(activation.ok) + assertTrue(activated) + assertTrue(handoff.ok) + assertTrue(takeover) + } + + @Test + fun `service rejects protocol mismatch`() = runBlocking { + val mismatched = request(RuntimeCommand.PING).copy(version = INSTANCE_PROTOCOL_VERSION + 1) + + val response = service().execute(mismatched) + + assertEquals("PROTOCOL_MISMATCH", response.code) + } + + private fun service(target: RuntimeCommandTarget = FakeRuntimeTarget()) = + RuntimeCommandService(OwnerKind.CLI_DAEMON, target) + + private fun request(command: RuntimeCommand, arguments: Map = emptyMap()) = InstanceRequest( + version = INSTANCE_PROTOCOL_VERSION, + token = "validated-by-coordinator", + requestId = "request-1", + command = command, + arguments = arguments, + ) +} + +private class FakeRuntimeTarget( + private val snapshot: RuntimeSnapshot = RuntimeSnapshot("idle", null, false), + private val sessionValues: List = emptyList(), + private val settingsValue: RuntimeSettingsSnapshot = RuntimeSettingsSnapshot(true, setOf("VIT")), + private val loginResult: RuntimeOperation = RuntimeOperation(true), + private val logoutResult: RuntimeOperation = RuntimeOperation(true), + private val setup: Boolean = true, +) : RuntimeCommandTarget { + var autoLoginValue: Boolean? = null + var allowedSsidsValue: Set? = null + var credentialUserId: String? = null + var credentialPassword: String? = null + + override suspend fun snapshot(): RuntimeSnapshot = snapshot + override suspend fun isSetup(): Boolean = setup + override suspend fun login(): RuntimeOperation = loginResult + override suspend fun logout(): RuntimeOperation = logoutResult + override suspend fun history(): List = sessionValues + override suspend fun settings(): RuntimeSettingsSnapshot = settingsValue + override suspend fun setAutoLogin(enabled: Boolean) { autoLoginValue = enabled } + override suspend fun setAllowedSsids(values: Set) { allowedSsidsValue = values } + override suspend fun setCredentials(userId: String, password: String) { + credentialUserId = userId + credentialPassword = password + } +} diff --git a/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/SessionRepositoryShutdownTest.kt b/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/SessionRepositoryShutdownTest.kt new file mode 100644 index 00000000..b35c5bd8 --- /dev/null +++ b/core/src/desktopTest/kotlin/com/vinnovateit/latch/core/runtime/SessionRepositoryShutdownTest.kt @@ -0,0 +1,55 @@ +package com.vinnovateit.latch.core.runtime + +import com.vinnovateit.latch.core.data.Session +import com.vinnovateit.latch.core.data.StatsDao +import com.vinnovateit.latch.core.domain.SessionRepository +import com.vinnovateit.latch.core.platform.ByteCounts +import com.vinnovateit.latch.core.platform.ByteCounterSource +import com.vinnovateit.latch.core.stats.ThroughputMonitor +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking + +class SessionRepositoryShutdownTest { + @Test + fun `awaited stop persists active session before returning`() = runBlocking { + val dao = RecordingStatsDao() + var bytes = 0L + val counters = object : ByteCounterSource { + override fun sample(): ByteCounts { + bytes += 2_048 + return ByteCounts(bytes, 0) + } + } + var clock = 0L + val monitor = ThroughputMonitor(counters, intervalMs = 1) { clock += 1; clock } + val repository = SessionRepository(dao, monitor) + + repository.startSession() + delay(25) + repository.stopSessionAndAwait() + + assertTrue(dao.inserted.isNotEmpty()) + assertTrue(dao.inserted.single().rxBytes >= 1_024) + } +} + +private class RecordingStatsDao : StatsDao { + val inserted = mutableListOf() + private val sessions = MutableStateFlow>(emptyList()) + + override suspend fun insertSession(session: Session): Long { + inserted += session + sessions.value += session + return inserted.size.toLong() + } + + override fun getAllSessions(): Flow> = sessions + + override suspend fun clearAllSessions() { + sessions.value = emptyList() + } +} diff --git a/core/src/desktopTest/kotlin/com/vinnovateit/latch/desktop/platform/JsonKeyValueStoreTest.kt b/core/src/desktopTest/kotlin/com/vinnovateit/latch/desktop/platform/JsonKeyValueStoreTest.kt new file mode 100644 index 00000000..eda69410 --- /dev/null +++ b/core/src/desktopTest/kotlin/com/vinnovateit/latch/desktop/platform/JsonKeyValueStoreTest.kt @@ -0,0 +1,32 @@ +package com.vinnovateit.latch.desktop.platform + +import com.vinnovateit.latch.core.platform.NoOpLogger +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class JsonKeyValueStoreTest { + @Test + fun `flush lands writes a short-lived process would otherwise lose`() { + val directory = createTempDirectory("latch-settings-").toFile() + val file = directory.resolve("settings.json") + try { + val store = JsonKeyValueStore(file, NoOpLogger) + + store.putStringSet("allowed_ssids", setOf("G-VIT")) + store.putBoolean("auto_login", false) + // Stands in for the process exiting: no scheduling window is given + // to the background writer. + store.flush() + + assertTrue(file.exists(), "settings were not on disk when flush() returned") + // A fresh store is what the next `latch-cli` invocation sees. + val reopened = JsonKeyValueStore(file, NoOpLogger) + assertEquals(setOf("G-VIT"), reopened.getStringSet("allowed_ssids", setOf("VIT"))) + assertEquals(false, reopened.getBoolean("auto_login", true)) + } finally { + directory.deleteRecursively() + } + } +} diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index d6b84a47..b9eac6e4 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -2,6 +2,8 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.io.* +val latchVersion = providers.gradleProperty("latchVersion").get() + plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.compose.multiplatform) @@ -143,7 +145,7 @@ compose.desktop { packageName = "Latch" // jpackage REQUIRES MAJOR.MINOR.PATCH with MAJOR >= 1. The Android // versionName "1.3" has only two components and would be rejected. - packageVersion = "1.3.8" + packageVersion = latchVersion description = "Auto-login for VIT hostel Wi-Fi" vendor = "VinnovateIT" copyright = "(c) 2026 VinnovateIT" @@ -189,7 +191,7 @@ tasks.register("packageReleaseTarGz") { description = "Packages release distributable directory into a .tar.gz archive" dependsOn("createReleaseDistributable") - archiveFileName.set("latch-1.3.8-linux-x64.tar.gz") + archiveFileName.set("latch-$latchVersion-linux-x64.tar.gz") destinationDirectory.set(layout.buildDirectory.dir("distributions")) compression = Compression.GZIP @@ -200,6 +202,6 @@ tasks.register("packageReleaseTarGz") { // expects (the previous "app" path produced an extra Latch/ nesting // level that broke the installer). from(layout.buildDirectory.dir("compose/binaries/main-release/app/Latch")) { - into("latch-1.3.8") + into("latch-$latchVersion") } } diff --git a/desktop/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/LatchApp.kt b/desktop/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/LatchApp.kt index d7943e61..a5b7233f 100644 --- a/desktop/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/LatchApp.kt +++ b/desktop/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/LatchApp.kt @@ -1,17 +1,10 @@ package com.vinnovateit.latch.desktop -import com.vinnovateit.latch.core.data.LatchDatabase -import com.vinnovateit.latch.core.data.buildDatabase -import com.vinnovateit.latch.core.domain.SessionRepository import com.vinnovateit.latch.core.engine.LatchCommand -import com.vinnovateit.latch.core.engine.LatchEngine -import com.vinnovateit.latch.core.platform.Platform -import com.vinnovateit.latch.core.platform.PlatformServices +import com.vinnovateit.latch.core.runtime.DesktopEngineRuntime import com.vinnovateit.latch.core.settings.SettingsManager -import com.vinnovateit.latch.core.stats.ThroughputMonitor import com.vinnovateit.latch.core.stats.formatBitsPerSecond import com.vinnovateit.latch.core.stats.formatClockTime -import com.vinnovateit.latch.desktop.platform.DesktopPlatformServices import com.vinnovateit.latch.desktop.platform.TrayNotifier import com.vinnovateit.latch.desktop.platform.windows.WindowsBalloonNotifier import com.vinnovateit.latch.desktop.updater.GithubUpdater @@ -20,6 +13,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull private const val APP_DISPLAY_NAME = "LATCH by VinnovateIT" @@ -34,36 +28,27 @@ private const val LATCH_WAIT_BEFORE_UPDATE_CHECK_MS = 60_000L * component happened to run first. */ class LatchApp private constructor( - val platform: PlatformServices, + internal val runtime: DesktopEngineRuntime, val notifier: TrayNotifier, - val sessions: SessionRepository, - val engine: LatchEngine, - private val database: LatchDatabase, val updater: GithubUpdater, ) { + val platform get() = runtime.platform + val sessions get() = runtime.sessions + val engine get() = runtime.engine + companion object { fun create(echoLogsToStdout: Boolean): LatchApp { val notifier = TrayNotifier() - val platform = DesktopPlatformServices( - echoLogsToStdout = echoLogsToStdout, - notifier = notifier, - ) - Platform.install(platform) - SettingsManager.initialize(platform.settingsStore) - - val database = buildDatabase() - val throughput = ThroughputMonitor(platform.counters) - val sessions = SessionRepository(database.statsDao(), throughput) - sessions.initialize() - - val engine = LatchEngine(platform, sessions) + val runtime = runBlocking { + DesktopEngineRuntime.create(notifier, echoLogsToStdout) + } val updater = GithubUpdater( - buildInfo = platform.buildInfo, - logger = platform.logger, + buildInfo = runtime.platform.buildInfo, + logger = runtime.platform.logger, ) - return LatchApp(platform, notifier, sessions, engine, database, updater) + return LatchApp(runtime, notifier, updater) } } @@ -72,7 +57,7 @@ class LatchApp private constructor( fun start() { if (AppPaths.isWindows) WindowsBalloonNotifier.start(platform.logger) applyAutostartDefault() - engine.start() + runtime.start() // Drive the tray tooltip from live session data. This is the 2-second // update path, so it must stay on showOngoing (tooltip) and never become @@ -176,9 +161,7 @@ class LatchApp private constructor( } fun shutdown() { - runCatching { - engine.submit(LatchCommand.Shutdown) - } + runCatching { runBlocking { runtime.close() } } } /** diff --git a/desktop/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/Main.kt b/desktop/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/Main.kt index 04d44e2c..2a0871d6 100644 --- a/desktop/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/Main.kt +++ b/desktop/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/Main.kt @@ -10,16 +10,22 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import com.sun.jna.platform.win32.Shell32 -import com.sun.jna.WString -import kotlinx.coroutines.launch import androidx.compose.ui.window.Tray import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberTrayState +import com.sun.jna.WString +import com.sun.jna.platform.win32.Shell32 import com.vinnovateit.latch.core.engine.LatchCommand -import com.vinnovateit.latch.core.settings.SettingsManager -import com.vinnovateit.latch.core.wifi.ConnectionStatus +import com.vinnovateit.latch.core.runtime.DesktopOwnership +import com.vinnovateit.latch.core.runtime.OwnerKind +import com.vinnovateit.latch.core.runtime.RuntimeCommandService +import com.vinnovateit.latch.core.runtime.claimDesktopOwnership import com.vinnovateit.latch.ui.LatchRoot +import java.awt.EventQueue +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking private const val APP_DISPLAY_NAME = "LATCH by VinnovateIT" @@ -32,117 +38,154 @@ private fun configureWindowsAppUserModelId() { fun main(args: Array) { var onActivateWindow: (() -> Unit)? = null - if (!SingleInstance.acquire { onActivateWindow?.invoke() }) { - kotlin.system.exitProcess(0) + val pendingActivation = AtomicBoolean(false) + val serviceReady = CompletableDeferred() + val ownership = runBlocking { + claimDesktopOwnership(AppPaths.dataDir) { request -> + serviceReady.await().execute(request) + } + } + val coordinator = when (ownership) { + is DesktopOwnership.Owner -> ownership.coordinator + DesktopOwnership.ActivatedExisting -> return + is DesktopOwnership.Failure -> { + System.err.println("Unable to start Latch: ${ownership.message}") + kotlin.system.exitProcess(1) + } } val startHidden = "--hidden" in args - val app = LatchApp.create(echoLogsToStdout = System.console() != null || !startHidden) + val app = try { + LatchApp.create(echoLogsToStdout = System.console() != null || !startHidden) + } catch (error: Exception) { + serviceReady.completeExceptionally(error) + coordinator.close() + throw error + } + serviceReady.complete( + RuntimeCommandService( + ownerKind = OwnerKind.DESKTOP, + runtime = app.runtime, + onActivateUi = { + EventQueue.invokeLater { + onActivateWindow?.invoke() ?: pendingActivation.set(true) + } + }, + ), + ) app.start() configureWindowsAppUserModelId() - application { - var windowVisible by remember { mutableStateOf(!startHidden) } - var restoreTrigger by remember { mutableStateOf(0) } - - val openLatch: () -> Unit = { - windowVisible = true - restoreTrigger++ - } - onActivateWindow = openLatch + try { + application { + var windowVisible by remember { mutableStateOf(!startHidden) } + var restoreTrigger by remember { mutableStateOf(0) } - val trayState = rememberTrayState() - val isLatched by app.engine.isLatched.collectAsState() - val status by app.engine.status.collectAsState() - val tooltip by app.notifier.tooltip.collectAsState() - val updateState by app.updater.state.collectAsState() + val openLatch: () -> Unit = { + windowVisible = true + restoreTrigger++ + } + onActivateWindow = openLatch + if (pendingActivation.getAndSet(false)) openLatch() - LaunchedEffect(trayState) { app.notifier.trayState = trayState } + val trayState = rememberTrayState() + val isLatched by app.engine.isLatched.collectAsState() + val status by app.engine.status.collectAsState() + val tooltip by app.notifier.tooltip.collectAsState() + val updateState by app.updater.state.collectAsState() - val isLinux = remember { System.getProperty("os.name").contains("Linux", ignoreCase = true) } - val useLinuxTray = remember { isLinux && com.vinnovateit.latch.desktop.platform.linux.LinuxAppIndicatorTray.isSupported() } + LaunchedEffect(trayState) { app.notifier.trayState = trayState } - val toggleConnect: () -> Unit = { - val currentStatus = app.engine.status.value - val latched = app.engine.isLatched.value - if (latched || currentStatus is ConnectionStatus.Connecting) { - app.engine.submit(LatchCommand.Logout) - SettingsManager.setAutoLogin(false) - } else { - SettingsManager.setAutoLogin(true) - app.engine.submit(LatchCommand.CheckAndLogin) + val isLinux = remember { System.getProperty("os.name").contains("Linux", ignoreCase = true) } + val useLinuxTray = remember { + isLinux && com.vinnovateit.latch.desktop.platform.linux.LinuxAppIndicatorTray.isSupported() } - } - LaunchedEffect(isLatched, status) { - val shouldShowDisconnect = isLatched || status is ConnectionStatus.Connecting - if (useLinuxTray) { - com.vinnovateit.latch.desktop.platform.linux.LinuxAppIndicatorTray.init( - isLatched = isLatched, - onOpenLatch = openLatch, - onToggleConnect = toggleConnect, - onExitLatch = { - com.vinnovateit.latch.desktop.platform.linux.LinuxAppIndicatorTray.stop() - app.shutdown() - kotlin.system.exitProcess(0) - }, - ) - com.vinnovateit.latch.desktop.platform.linux.LinuxAppIndicatorTray.updateStatus(shouldShowDisconnect) - } else if (isLinux) { - kotlinx.coroutines.delay(200) - runCatching { patchLinuxTrayIconAlpha(isLatched, openLatch) } + val toggleConnect: () -> Unit = { + val currentStatus = app.engine.status.value + val latched = app.engine.isLatched.value + if (latched || currentStatus is com.vinnovateit.latch.core.wifi.ConnectionStatus.Connecting) { + app.engine.submit(LatchCommand.Logout) + } else { + app.engine.submit(LatchCommand.CheckAndLogin) + } } - } - if (!useLinuxTray) { - Tray( - state = trayState, - icon = remember(isLatched) { LatchIcon.forTray(latched = isLatched) }, - tooltip = tooltip, - onAction = openLatch, - menu = { - Item("Open Latch", onClick = openLatch) - Separator() - if (isLatched || status is ConnectionStatus.Connecting) { - Item("Disconnect", onClick = toggleConnect) - } else { - Item("Connect", onClick = toggleConnect) - } - Separator() - Item("Exit Latch", onClick = { - app.shutdown() - exitApplication() - }) - }, - ) - } + LaunchedEffect(isLatched, status) { + val shouldShowDisconnect = isLatched || status is com.vinnovateit.latch.core.wifi.ConnectionStatus.Connecting + if (useLinuxTray) { + com.vinnovateit.latch.desktop.platform.linux.LinuxAppIndicatorTray.init( + isLatched = isLatched, + onOpenLatch = openLatch, + onToggleConnect = toggleConnect, + onExitLatch = { + com.vinnovateit.latch.desktop.platform.linux.LinuxAppIndicatorTray.stop() + app.shutdown() + coordinator.close() + kotlin.system.exitProcess(0) + }, + ) + com.vinnovateit.latch.desktop.platform.linux.LinuxAppIndicatorTray.updateStatus(shouldShowDisconnect) + } else if (isLinux) { + kotlinx.coroutines.delay(200) + runCatching { patchLinuxTrayIconAlpha(isLatched, openLatch) } + } + } - LatchWindow( - visible = windowVisible, - restoreTrigger = restoreTrigger, - onCloseRequest = { windowVisible = false }, - ) { onMinimize, onClose -> - val scope = rememberCoroutineScope() - Surface(modifier = Modifier.fillMaxSize()) { - LatchRoot( - controller = app.engine, - sessions = app.sessions, - platform = app.platform, - updateState = updateState, - onMinimize = onMinimize, - onClose = onClose, - onCheckForUpdates = { scope.launch { app.updater.check(force = true) } }, - onDownloadUpdate = { app.downloadUpdate() }, - onCancelDownload = { app.cancelUpdateDownload() }, - // Leave only if the installer really started; on a failure - // installAndExit has an error for the user to read, which - // exiting unconditionally would take down with the process. - onInstallUpdate = { path -> - if (app.updater.installAndExit(path)) exitApplication() + if (!useLinuxTray) { + Tray( + state = trayState, + icon = remember(isLatched) { LatchIcon.forTray(latched = isLatched) }, + tooltip = tooltip, + onAction = openLatch, + menu = { + Item("Open Latch", onClick = openLatch) + Separator() + if (isLatched || status is com.vinnovateit.latch.core.wifi.ConnectionStatus.Connecting) { + Item("Disconnect", onClick = toggleConnect) + } else { + Item("Connect", onClick = toggleConnect) + } + Separator() + Item("Exit Latch", onClick = { + app.shutdown() + coordinator.close() + exitApplication() + }) }, - onDismissUpdate = { app.updater.dismissUpdate() }, ) } + + LatchWindow( + visible = windowVisible, + restoreTrigger = restoreTrigger, + onCloseRequest = { windowVisible = false }, + ) { onMinimize, onClose -> + val scope = rememberCoroutineScope() + Surface(modifier = Modifier.fillMaxSize()) { + LatchRoot( + controller = app.engine, + sessions = app.sessions, + platform = app.platform, + updateState = updateState, + onMinimize = onMinimize, + onClose = onClose, + onCheckForUpdates = { scope.launch { app.updater.check(force = true) } }, + onDownloadUpdate = { app.downloadUpdate() }, + onCancelDownload = { app.cancelUpdateDownload() }, + // Leave only if the installer really started; on a failure + // installAndExit has an error for the user to read, which + // exiting unconditionally would take down with the process. + onInstallUpdate = { path -> + if (app.updater.installAndExit(path)) exitApplication() + }, + onDismissUpdate = { app.updater.dismissUpdate() }, + ) + } + } } + } finally { + app.shutdown() + coordinator.close() } -} +} \ No newline at end of file diff --git a/desktop/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/SingleInstance.kt b/desktop/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/SingleInstance.kt deleted file mode 100644 index 7efe50ad..00000000 --- a/desktop/src/desktopMain/kotlin/com/vinnovateit/latch/desktop/SingleInstance.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.vinnovateit.latch.desktop - -import java.io.File -import java.io.RandomAccessFile -import java.net.InetAddress -import java.net.ServerSocket -import java.net.Socket -import java.nio.channels.FileChannel -import java.nio.channels.FileLock -import kotlin.concurrent.thread - -/** - * Strict single-instance guard. - * - * Ensures only ONE Latch instance can run concurrently. When a second (younger) - * instance launches: - * 1. Signals the older active instance to bring its window to front. - * 2. Younger instance immediately self-terminates. - * 3. Automatically recovers from stale locks or crashed previous instances. - */ -internal object SingleInstance { - private var lock: FileLock? = null - private var channelRef: FileChannel? = null - private var serverSocket: ServerSocket? = null - - private val lockFile: File get() = AppPaths.dataDir.resolve(".lock") - private val portFile: File get() = AppPaths.dataDir.resolve(".port") - private val pidFile: File get() = AppPaths.dataDir.resolve(".pid") - - /** - * @param onActivate Callback invoked on the running instance when a second instance tries to launch. - * @return true if this process acquired the exclusive lock and is the sole instance. - */ - fun acquire(onActivate: () -> Unit): Boolean { - try { - lockFile.parentFile?.mkdirs() - - // 1. Try file lock - val channel = RandomAccessFile(lockFile, "rw").channel - val acquired = runCatching { channel.tryLock() }.getOrNull() - - if (acquired != null) { - lock = acquired - channelRef = channel - recordCurrentPid() - startServer(onActivate) - registerShutdownHook() - return true - } - - // Lock is held by another process -- try notifying it to show its window - runCatching { channel.close() } - val notified = notifyRunningInstance() - - if (notified) { - // Older instance was notified and will show itself; younger instance exits - return false - } - - // Socket notification failed: check if the process holding lock is actually alive - val existingPid = readExistingPid() - val isAlive = existingPid != null && isProcessAlive(existingPid) - - if (!isAlive) { - // Stale lock detected (process died abruptly): clean and retry once - runCatching { lockFile.delete() } - runCatching { portFile.delete() } - runCatching { pidFile.delete() } - - val retryChannel = RandomAccessFile(lockFile, "rw").channel - val retryLock = runCatching { retryChannel.tryLock() }.getOrNull() - if (retryLock != null) { - lock = retryLock - channelRef = retryChannel - recordCurrentPid() - startServer(onActivate) - registerShutdownHook() - return true - } - } - - // Active instance exists; self-terminate - return false - } catch (_: Throwable) { - val existingPid = readExistingPid() - if (existingPid != null && isProcessAlive(existingPid)) { - return false - } - return true - } - } - - private fun notifyRunningInstance(): Boolean { - return try { - if (!portFile.exists()) return false - val port = portFile.readText().trim().toIntOrNull() ?: return false - Socket(InetAddress.getByName("127.0.0.1"), port).use { socket -> - socket.soTimeout = 2000 - val out = socket.getOutputStream() - out.write("SHOW\n".toByteArray(Charsets.UTF_8)) - out.flush() - } - true - } catch (_: Throwable) { - false - } - } - - private fun startServer(onActivate: () -> Unit) { - runCatching { - val server = ServerSocket(0, 50, InetAddress.getByName("127.0.0.1")) - serverSocket = server - portFile.writeText(server.localPort.toString()) - - thread(isDaemon = true, name = "SingleInstanceListener") { - while (!server.isClosed) { - try { - val client = server.accept() - client.use { - val msg = it.getInputStream().bufferedReader().readLine() - if (msg == "SHOW") { - java.awt.EventQueue.invokeLater { - onActivate() - } - } - } - } catch (_: Throwable) { - break - } - } - } - } - } - - private fun recordCurrentPid() { - runCatching { - val pid = ProcessHandle.current().pid() - pidFile.writeText(pid.toString()) - } - } - - private fun readExistingPid(): Long? { - return runCatching { - if (pidFile.exists()) pidFile.readText().trim().toLongOrNull() else null - }.getOrNull() - } - - private fun isProcessAlive(pid: Long): Boolean { - return runCatching { - ProcessHandle.of(pid).map { it.isAlive }.orElse(false) - }.getOrDefault(false) - } - - private fun registerShutdownHook() { - Runtime.getRuntime().addShutdownHook(Thread { - runCatching { serverSocket?.close() } - runCatching { lock?.release() } - runCatching { channelRef?.close() } - runCatching { portFile.delete() } - runCatching { pidFile.delete() } - runCatching { lockFile.delete() } - }) - } -} diff --git a/gradle.properties b/gradle.properties index 39912f3b..8a963d63 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,6 +16,7 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official +latchVersion=1.3.8 # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library @@ -23,4 +24,4 @@ android.nonTransitiveRClass=true # AGP 9 New Architecture android.builtInKotlin=true -android.newDsl=true \ No newline at end of file +android.newDsl=true diff --git a/packaging/generate-cli-package-metadata.sh b/packaging/generate-cli-package-metadata.sh new file mode 100755 index 00000000..6c812dd3 --- /dev/null +++ b/packaging/generate-cli-package-metadata.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 4 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +version=$1 +linux_archive=$2 +windows_archive=$3 +output_dir=$4 + +if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid version: $version" >&2 + exit 2 +fi + +expected_linux="latch-cli-${version}-linux-x64.tar.gz" +expected_windows="latch-cli-${version}-windows-x64.zip" + +if [[ $(basename "$linux_archive") != "$expected_linux" ]]; then + echo "Expected Linux artifact named $expected_linux" >&2 + exit 2 +fi +if [[ $(basename "$windows_archive") != "$expected_windows" ]]; then + echo "Expected Windows artifact named $expected_windows" >&2 + exit 2 +fi +if [[ ! -f $linux_archive || ! -f $windows_archive ]]; then + echo "Both release artifacts must exist" >&2 + exit 2 +fi + +linux_sha=$(sha256sum "$linux_archive" | awk '{print $1}') +windows_sha=$(sha256sum "$windows_archive" | awk '{print toupper($1)}') +aur_dir="$output_dir/aur" +winget_dir="$output_dir/winget/VinnovateIT.LatchCLI/$version" +mkdir -p "$aur_dir" "$winget_dir" + +cat > "$aur_dir/PKGBUILD" < "$aur_dir/.SRCINFO" < "$winget_dir/VinnovateIT.LatchCLI.yaml" < "$winget_dir/VinnovateIT.LatchCLI.installer.yaml" < "$winget_dir/VinnovateIT.LatchCLI.locale.en-US.yaml" <