diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 0000000..4b0b204 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,148 @@ +name: Desktop release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + version: + description: "Version to build (e.g. 1.1.4). Overrides tag when set." + required: true + type: string + +concurrency: + group: desktop-release-${{ github.ref }}-${{ inputs.version || github.ref_name }} + cancel-in-progress: true + +jobs: + resolve-version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.ver.outputs.version }} + tag: ${{ steps.ver.outputs.tag }} + steps: + - id: ver + shell: bash + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + V="${{ inputs.version }}" + else + V="${GITHUB_REF_NAME#v}" + fi + if [[ ! "$V" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-].*)?$ ]]; then + echo "Invalid version: $V" >&2 + exit 1 + fi + echo "version=$V" >> "$GITHUB_OUTPUT" + echo "tag=v$V" >> "$GITHUB_OUTPUT" + + macos: + needs: resolve-version + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - name: Set versionName + run: | + sed -i.bak 's/extra\["versionName"\] = "[^"]*"/extra["versionName"] = "${{ needs.resolve-version.outputs.version }}"/' build.gradle.kts + - name: Install create-dmg + run: brew install create-dmg + - name: Package macOS DMG + run: | + export FROMCHAT_PACKAGING_JDK="$JAVA_HOME" + ./gradlew :app:desktop:packageReleaseMac --no-daemon + - uses: actions/upload-artifact@v4 + with: + name: desktop-macos + path: app/desktop/build/distributions/release/*.dmg + if-no-files-found: error + + linux: + needs: resolve-version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - name: Install packaging tools + run: | + sudo apt-get update + sudo apt-get install -y fakeroot rpm binutils libfuse2 wget + wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O /tmp/appimagetool + chmod +x /tmp/appimagetool + echo "APPIMAGETOOL=/tmp/appimagetool" >> "$GITHUB_ENV" + - name: Set versionName + run: | + sed -i 's/extra\["versionName"\] = "[^"]*"/extra["versionName"] = "${{ needs.resolve-version.outputs.version }}"/' build.gradle.kts + - name: Package Linux + run: | + export FROMCHAT_PACKAGING_JDK="$JAVA_HOME" + ./gradlew :app:desktop:packageReleaseLinux --no-daemon + - uses: actions/upload-artifact@v4 + with: + name: desktop-linux + path: | + app/desktop/build/distributions/release/*.deb + app/desktop/build/distributions/release/*.rpm + app/desktop/build/distributions/release/*.AppImage + if-no-files-found: error + + windows: + needs: resolve-version + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + - name: Set versionName + shell: bash + run: | + sed -i 's/extra\["versionName"\] = "[^"]*"/extra["versionName"] = "${{ needs.resolve-version.outputs.version }}"/' build.gradle.kts + - name: Package Windows + shell: bash + run: | + export FROMCHAT_PACKAGING_JDK="$JAVA_HOME" + ./gradlew :app:desktop:packageReleaseWindows --no-daemon + - uses: actions/upload-artifact@v4 + with: + name: desktop-windows + path: | + app/desktop/build/distributions/release/*.exe + if-no-files-found: error + + publish: + needs: [resolve-version, macos, linux, windows] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Collect assets + run: | + mkdir -p release-assets + find artifacts -type f \( -name '*.dmg' -o -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' -o -name '*.exe' \) -exec cp {} release-assets/ \; + ls -la release-assets + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.resolve-version.outputs.tag }} + name: Desktop ${{ needs.resolve-version.outputs.version }} + draft: false + generate_release_notes: true + files: release-assets/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.en.md b/README.en.md index bea6823..1e22a72 100644 --- a/README.en.md +++ b/README.en.md @@ -29,6 +29,25 @@ FromChat is a 100% free and open-source messenger. This repository is the cross- Desktop replaces the former Electron client in the Web repo. Run with `./gradlew :app:desktop:run`. +### Desktop packaging + +Build release packages on the matching OS (or via CI): + +| Task | Host | Artifacts | +|------|------|-----------| +| `./gradlew :app:desktop:packageReleaseMac` | macOS | `FromChat--macOS.dmg` | +| `./gradlew :app:desktop:packageReleaseLinux` | Linux | `.deb`, `.rpm`, `.AppImage` (needs `appimagetool`) | +| `./gradlew :app:desktop:packageReleaseWindows` | Windows | `FromChat-Setup-.exe`, `FromChat-Portable-.exe` (Rust toolchain required) | +| `./gradlew :app:desktop:packageReleaseDesktop` | current OS | delegates to the task above | + +Outputs land under `app/desktop/build/distributions/release/`. + +**CI:** `.github/workflows/desktop-release.yml` runs on `v*` tags and `workflow_dispatch` (required `version` input), then uploads assets to a GitHub Release. + +**Windows portable:** extracts into a folder where only `FromChat Portable.exe` is visible; other runtime files are Hidden. Data is stored in a Hidden `fromchat-data/` next to that EXE (`-Dfromchat.portable=true`). + +**Installed data:** Windows `%LOCALAPPDATA%\FromChat`, macOS `~/Library/Application Support/FromChat`, Linux `~/.local/share/FromChat` (migrates from legacy `~/.fromchat/cache` when present). + --- ## 🏗️ Tech Stack diff --git a/README.md b/README.md index 0e3fd7b..b39ddbc 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,25 @@ FromChat — 100% бесплатный и открытый мессенджер. Desktop заменяет бывший Electron-клиент в репозитории Web. Запуск: `./gradlew :app:desktop:run`. +### Сборка Desktop + +Релизные пакеты собираются на соответствующей ОС (или в CI): + +| Задача | Хост | Артефакты | +|--------|------|-----------| +| `./gradlew :app:desktop:packageReleaseMac` | macOS | `FromChat--macOS.dmg` | +| `./gradlew :app:desktop:packageReleaseLinux` | Linux | `.deb`, `.rpm`, `.AppImage` (нужен `appimagetool`) | +| `./gradlew :app:desktop:packageReleaseWindows` | Windows | `FromChat-Setup-.exe`, `FromChat-Portable-.exe` (нужен Rust) | +| `./gradlew :app:desktop:packageReleaseDesktop` | текущая ОС | делегирует в задачу выше | + +Результаты: `app/desktop/build/distributions/release/`. + +**CI:** `.github/workflows/desktop-release.yml` на тегах `v*` и `workflow_dispatch` (обязательный input `version`), затем загрузка в GitHub Release. + +**Windows portable:** в папке виден только `FromChat Portable.exe`; остальной runtime — Hidden. Данные в Hidden `fromchat-data/` рядом с EXE. + +**Установка:** Windows `%LOCALAPPDATA%\FromChat`, macOS `~/Library/Application Support/FromChat`, Linux `~/.local/share/FromChat` (миграция с `~/.fromchat/cache` при наличии). + --- ## 🏗️ Технологический стек diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts index e919ced..59fb0ee 100644 --- a/app/desktop/build.gradle.kts +++ b/app/desktop/build.gradle.kts @@ -35,16 +35,42 @@ val releaseDmgOutput = layout.buildDirectory.file("distributions/FromChat.dmg") fun resolvePackagingJdkHome(): String { System.getenv("FROMCHAT_PACKAGING_JDK")?.takeIf { it.isNotBlank() }?.let { return it } - val candidates = listOf( - "${System.getProperty("user.home")}/.gradle/jdks/eclipse_adoptium-17-aarch64-os_x.2/jdk-17.0.19+10/Contents/Home", - "/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home", - "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home", - "/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home", + System.getenv("JAVA_HOME")?.takeIf { it.isNotBlank() }?.let { home -> + if (File(home, "bin/jpackage").isFile || File(home, "bin/jpackage.exe").isFile) { + return home + } + } + val userHome = System.getProperty("user.home") + val os = System.getProperty("os.name").orEmpty().lowercase() + val candidates = buildList { + if (os.contains("mac")) { + add("$userHome/.gradle/jdks/eclipse_adoptium-17-aarch64-os_x.2/jdk-17.0.19+10/Contents/Home") + add("/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home") + add("/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home") + add("/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home") + add("/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home") + } + if (os.contains("win")) { + add("C:/Program Files/Eclipse Adoptium/jdk-17") + add("C:/Program Files/Eclipse Adoptium/jdk-21") + add("C:/Program Files/Java/jdk-17") + add("C:/Program Files/Microsoft/jdk-17") + } + if (os.contains("linux")) { + add("/usr/lib/jvm/temurin-17-jdk-amd64") + add("/usr/lib/jvm/temurin-21-jdk-amd64") + add("/usr/lib/jvm/java-17-openjdk-amd64") + add("/usr/lib/jvm/java-21-openjdk-amd64") + add("/usr/lib/jvm/java-17-openjdk") + add("/usr/lib/jvm/java-21-openjdk") + } + System.getProperty("java.home")?.let { add(it) } + } + return candidates.firstOrNull { + File(it, "bin/jpackage").isFile || File(it, "bin/jpackage.exe").isFile + } ?: error( + "No JDK with jpackage found. Install Temurin 17+ or set FROMCHAT_PACKAGING_JDK.", ) - return candidates.firstOrNull { File(it, "bin/jpackage").isFile } - ?: error( - "No JDK with jpackage found. Install Temurin 17+ or set FROMCHAT_PACKAGING_JDK.", - ) } compose.desktop { @@ -58,11 +84,29 @@ compose.desktop { nativeDistributions { packageName = "FromChat" - packageVersion = "1.0.0" + packageVersion = rootProject.extra["versionName"] as String description = "FromChat desktop" copyright = "© FromChat" includeAllModules = true + // macOS DMG uses the custom create-dmg pipeline (packageReleaseDmg), not jpackage. + // Linux .AppImage is built via appimagetool (packageLinuxAppImage), not TargetFormat.AppImage. + val osName = System.getProperty("os.name").orEmpty().lowercase() + when { + osName.contains("linux") -> { + targetFormats( + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb, + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Rpm, + ) + } + osName.contains("win") -> { + // App-image via createReleaseDistributable; custom Rust setup wraps it. + targetFormats( + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Exe, + ) + } + } + modules( "javafx.base", "javafx.graphics", @@ -482,3 +526,209 @@ tasks.register("packageDmg") { description = "Alias for packageReleaseDmg (full release DMG pipeline)." dependsOn("packageReleaseDmg") } + +val desktopVersionName = rootProject.extra["versionName"] as String +val desktopDistDir = layout.buildDirectory.dir("distributions/release") +val runningOnLinux = System.getProperty("os.name").orEmpty().lowercase().contains("linux") +val runningOnWindows = System.getProperty("os.name").orEmpty().lowercase().contains("win") + +val releaseAppImageDir = layout.buildDirectory.dir("compose/binaries/main-release/app/FromChat") +val linuxAppImageOutput = desktopDistDir.map { it.file("FromChat-$desktopVersionName-linux.AppImage") } +val windowsSetupOutput = desktopDistDir.map { it.file("FromChat-Setup-$desktopVersionName.exe") } +val macDmgReleaseOutput = desktopDistDir.map { it.file("FromChat-$desktopVersionName-macOS.dmg") } + +tasks.register("packageReleaseMac") { + group = "compose desktop" + description = "Build release macOS DMG and copy to distributions/release." + onlyIf { runningOnMacOs } + dependsOn("packageReleaseDmg") + outputs.file(macDmgReleaseOutput) + doLast { + val outDir = desktopDistDir.get().asFile + outDir.mkdirs() + val src = releaseDmgOutput.get().asFile + check(src.isFile) { "Missing ${src.absolutePath}" } + src.copyTo(macDmgReleaseOutput.get().asFile, overwrite = true) + } +} + +val packageLinuxAppImage = tasks.register("packageLinuxAppImage") { + group = "compose desktop" + description = "Build a Linux .AppImage from the release app-image using appimagetool." + onlyIf { runningOnLinux } + dependsOn("createReleaseDistributable") + inputs.dir(releaseAppImageDir) + outputs.file(linuxAppImageOutput) + doFirst { + val appDir = releaseAppImageDir.get().asFile + check(appDir.isDirectory) { "Missing ${appDir.absolutePath}" } + val out = linuxAppImageOutput.get().asFile + out.parentFile.mkdirs() + val stage = layout.buildDirectory.get().asFile.resolve("appimage-stage/FromChat.AppDir") + stage.deleteRecursively() + stage.mkdirs() + appDir.copyRecursively(stage, overwrite = true) + val nestedExe = sequenceOf( + stage.resolve("FromChat"), + stage.resolve("bin/FromChat"), + ).firstOrNull { it.isFile } + ?: stage.walkTopDown().firstOrNull { it.isFile && it.name == "FromChat" && !it.path.contains("/runtime/") } + ?: error("FromChat executable not found under ${appDir.absolutePath}") + val appRun = stage.resolve("AppRun") + val rel = stage.toPath().relativize(nestedExe.toPath()).toString() + appRun.writeText( + """ + #!/bin/sh + SELF="${'$'}(dirname "${'$'}(readlink -f "${'$'}0")")" + exec "${'$'}SELF/$rel" "${'$'}@" + """.trimIndent() + "\n", + ) + appRun.setExecutable(true) + nestedExe.setExecutable(true) + stage.resolve("fromchat.desktop").writeText( + """ + [Desktop Entry] + Name=FromChat + Exec=AppRun + Icon=fromchat + Type=Application + Categories=Network;InstantMessaging; + """.trimIndent() + "\n", + ) + val iconSrc = desktopWindowIconPng.asFile + if (iconSrc.isFile) { + iconSrc.copyTo(stage.resolve("fromchat.png"), overwrite = true) + } + commandLine( + "bash", + "-lc", + """ + set -euo pipefail + TOOL="${'$'}{APPIMAGETOOL:-appimagetool}" + if ! command -v "${'$'}TOOL" >/dev/null 2>&1; then + echo "appimagetool not found. Install it or set APPIMAGETOOL." >&2 + exit 1 + fi + ARCH="${'$'}(uname -m)" + export ARCH + "${'$'}TOOL" "${stage.absolutePath}" "${out.absolutePath}" + """.trimIndent(), + ) + } +} + +tasks.register("packageReleaseLinux") { + group = "compose desktop" + description = "Build release Linux deb, rpm, and AppImage." + onlyIf { runningOnLinux } + if (runningOnLinux) { + dependsOn("packageReleaseDeb", "packageReleaseRpm", packageLinuxAppImage) + } + doLast { + val outDir = desktopDistDir.get().asFile + outDir.mkdirs() + val binaries = layout.buildDirectory.get().asFile.resolve("compose/binaries/main-release") + binaries.walkTopDown() + .filter { it.isFile && (it.extension == "deb" || it.extension == "rpm") } + .forEach { file -> + val renamed = "FromChat-$desktopVersionName-linux.${file.extension}" + file.copyTo(outDir.resolve(renamed), overwrite = true) + } + } +} + +val windowsSetupDir = layout.projectDirectory.dir("windows-setup") +val windowsRustReleaseDir = windowsSetupDir.dir("target/release") +val windowsRustBinaryNames = listOf( + "fromchat-setup.exe", + "fromchat-setup-helper.exe", + "fromchat-portable-launcher.exe", + "fromchat-pack.exe", +) + +val buildWindowsSetupRust = tasks.register("buildWindowsSetupRust") { + group = "compose desktop" + description = "Build Rust setup, helper, and portable launcher (Windows only)." + onlyIf { runningOnWindows } + workingDir = windowsSetupDir.asFile + commandLine("cargo", "build", "--release", "--workspace") + inputs.dir(windowsSetupDir.dir("setup")) + inputs.dir(windowsSetupDir.dir("helper")) + inputs.dir(windowsSetupDir.dir("portable-launcher")) + inputs.dir(windowsSetupDir.dir("common")) + inputs.dir(windowsSetupDir.dir("pack")) + inputs.dir(windowsSetupDir.dir("assets")) + inputs.file(windowsSetupDir.file("Cargo.toml")) + windowsRustBinaryNames.forEach { name -> + outputs.file(windowsRustReleaseDir.file(name)) + } +} + +fun Exec.configureWindowsPackTask() { + inputs.dir(releaseAppImageDir).withPropertyName("appImage") + inputs.dir(windowsRustReleaseDir).withPropertyName("windowsSetupRust") + inputs.property("packVersion", desktopVersionName) + outputs.file(windowsSetupOutput) + doFirst { + val appDir = releaseAppImageDir.get().asFile + check(appDir.isDirectory) { "Missing ${appDir.absolutePath}" } + val outDir = desktopDistDir.get().asFile + outDir.mkdirs() + val releaseDir = windowsRustReleaseDir.asFile + val packer = listOf("fromchat-pack.exe", "fromchat-pack") + .map { releaseDir.resolve(it) } + .firstOrNull { it.isFile } + ?: error("Missing fromchat-pack. Build windows-setup workspace first.") + val setupBin = releaseDir.resolve("fromchat-setup.exe").takeIf { it.isFile } + ?: releaseDir.resolve("fromchat-setup") + val helperBin = releaseDir.resolve("fromchat-setup-helper.exe").takeIf { it.isFile } + ?: releaseDir.resolve("fromchat-setup-helper") + val launcherBin = releaseDir.resolve("fromchat-portable-launcher.exe").takeIf { it.isFile } + ?: releaseDir.resolve("fromchat-portable-launcher") + commandLine( + packer.absolutePath, + "--app-image", + appDir.absolutePath, + "--version", + desktopVersionName, + "--setup-out", + windowsSetupOutput.get().asFile.absolutePath, + "--setup-bin", + setupBin.absolutePath, + "--helper-bin", + helperBin.absolutePath, + "--launcher-bin", + launcherBin.absolutePath, + ) + } +} + +tasks.register("packSetupOnly") { + group = "compose desktop" + description = "Repack setup EXE from existing app-image + Rust (skips ProGuard)." + onlyIf { runningOnWindows } + dependsOn(buildWindowsSetupRust) + configureWindowsPackTask() +} + +tasks.register("packageReleaseWindows") { + group = "compose desktop" + description = "Build release Windows app-image, then setup EXE." + onlyIf { runningOnWindows } + if (runningOnWindows) { + dependsOn("createReleaseDistributable", buildWindowsSetupRust) + } + configureWindowsPackTask() +} + +tasks.register("packageReleaseDesktop") { + group = "compose desktop" + description = "Build the release desktop package for the current OS." + when { + runningOnMacOs -> dependsOn("packageReleaseMac") + runningOnLinux -> dependsOn("packageReleaseLinux") + runningOnWindows -> dependsOn("packageReleaseWindows") + else -> doFirst { error("Unsupported OS for packageReleaseDesktop") } + } +} + diff --git a/app/desktop/windows-setup/.gitignore b/app/desktop/windows-setup/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/app/desktop/windows-setup/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/app/desktop/windows-setup/Cargo.toml b/app/desktop/windows-setup/Cargo.toml new file mode 100644 index 0000000..cf92d8f --- /dev/null +++ b/app/desktop/windows-setup/Cargo.toml @@ -0,0 +1,31 @@ +[workspace] +resolver = "2" +members = [ + "common", + "setup", + "helper", + "portable-launcher", + "pack", +] + +[workspace.package] +version = "1.0.0" +edition = "2021" +license = "MIT" + +[workspace.dependencies] +fromchat-setup-common = { path = "common" } +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +zstd = "0.13" +tar = "0.4" +clap = { version = "4", features = ["derive"] } +eframe = { version = "0.29", default-features = false, features = ["default_fonts", "glow", "wgpu"] } +egui = "0.29" + +[profile.release] +lto = true +codegen-units = 1 +opt-level = "s" +strip = true diff --git a/app/desktop/windows-setup/README.md b/app/desktop/windows-setup/README.md new file mode 100644 index 0000000..ecc5630 --- /dev/null +++ b/app/desktop/windows-setup/README.md @@ -0,0 +1,6 @@ +# Программа установки FromChat + +*Честно признаюсь, это 100% вайбкод. Rust я не знаю. Просьба не предьявлять претензии по этому поводу.* + +Это программа для установки FromChat на Windows. У нее есть 2 режима: портативная версия (распакует в папку) и полная (с регистрацией, ярлыком на рабочем столе и т.д.). + diff --git a/app/desktop/windows-setup/assets/MaterialSymbolsOutlined.ttf b/app/desktop/windows-setup/assets/MaterialSymbolsOutlined.ttf new file mode 100644 index 0000000..12e349d Binary files /dev/null and b/app/desktop/windows-setup/assets/MaterialSymbolsOutlined.ttf differ diff --git a/app/desktop/windows-setup/assets/app_window_icon.png b/app/desktop/windows-setup/assets/app_window_icon.png new file mode 100644 index 0000000..c0e179f Binary files /dev/null and b/app/desktop/windows-setup/assets/app_window_icon.png differ diff --git a/app/desktop/windows-setup/assets/fonts/google_sans_bold.ttf b/app/desktop/windows-setup/assets/fonts/google_sans_bold.ttf new file mode 100644 index 0000000..71b847f Binary files /dev/null and b/app/desktop/windows-setup/assets/fonts/google_sans_bold.ttf differ diff --git a/app/desktop/windows-setup/assets/fonts/google_sans_medium.ttf b/app/desktop/windows-setup/assets/fonts/google_sans_medium.ttf new file mode 100644 index 0000000..8b9aebc Binary files /dev/null and b/app/desktop/windows-setup/assets/fonts/google_sans_medium.ttf differ diff --git a/app/desktop/windows-setup/assets/fonts/google_sans_regular.ttf b/app/desktop/windows-setup/assets/fonts/google_sans_regular.ttf new file mode 100644 index 0000000..cc37c3f Binary files /dev/null and b/app/desktop/windows-setup/assets/fonts/google_sans_regular.ttf differ diff --git a/app/desktop/windows-setup/assets/fonts/montserrat_bold.ttf b/app/desktop/windows-setup/assets/fonts/montserrat_bold.ttf new file mode 100644 index 0000000..a3dbde5 Binary files /dev/null and b/app/desktop/windows-setup/assets/fonts/montserrat_bold.ttf differ diff --git a/app/desktop/windows-setup/assets/fonts/montserrat_cyrillic.ttf b/app/desktop/windows-setup/assets/fonts/montserrat_cyrillic.ttf new file mode 100644 index 0000000..57728fe Binary files /dev/null and b/app/desktop/windows-setup/assets/fonts/montserrat_cyrillic.ttf differ diff --git a/app/desktop/windows-setup/assets/fonts/montserrat_latin.ttf b/app/desktop/windows-setup/assets/fonts/montserrat_latin.ttf new file mode 100644 index 0000000..b99369a Binary files /dev/null and b/app/desktop/windows-setup/assets/fonts/montserrat_latin.ttf differ diff --git a/app/desktop/windows-setup/assets/welcome_brand_bg.html b/app/desktop/windows-setup/assets/welcome_brand_bg.html new file mode 100644 index 0000000..731d7d6 --- /dev/null +++ b/app/desktop/windows-setup/assets/welcome_brand_bg.html @@ -0,0 +1,92 @@ + + + + + FromChat installer welcome backdrop + + + +
+ + + diff --git a/app/desktop/windows-setup/assets/welcome_brand_bg.png b/app/desktop/windows-setup/assets/welcome_brand_bg.png new file mode 100644 index 0000000..d456346 Binary files /dev/null and b/app/desktop/windows-setup/assets/welcome_brand_bg.png differ diff --git a/app/desktop/windows-setup/common/Cargo.toml b/app/desktop/windows-setup/common/Cargo.toml new file mode 100644 index 0000000..0fff744 --- /dev/null +++ b/app/desktop/windows-setup/common/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "fromchat-setup-common" +version.workspace = true +edition.workspace = true + +[dependencies] +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +zstd.workspace = true +tar.workspace = true +image = { version = "0.25", default-features = false, features = ["png"] } +ico = "0.4" + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_System_Registry", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", + "Win32_System_Com", + "Win32_System_Threading", + "Win32_Security", + "Win32_System_Pipes", + "Win32_System_IO", + "Win32_System_Memory", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_ProcessStatus", +] } diff --git a/app/desktop/windows-setup/common/src/branding.rs b/app/desktop/windows-setup/common/src/branding.rs new file mode 100644 index 0000000..b6f914f --- /dev/null +++ b/app/desktop/windows-setup/common/src/branding.rs @@ -0,0 +1,36 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub const BRANDING_PNG: &[u8] = include_bytes!("../../assets/app_window_icon.png"); + +const ICO_SIZES: [u32; 4] = [16, 32, 48, 256]; + +pub fn write_install_icon(install_dir: &Path) -> Result { + fs::create_dir_all(install_dir)?; + let ico_path = install_dir.join("FromChat.ico"); + if !ico_path.is_file() { + let bytes = png_bytes_to_ico(BRANDING_PNG)?; + fs::write(&ico_path, bytes).with_context(|| format!("write {}", ico_path.display()))?; + } + Ok(ico_path) +} + +pub fn png_bytes_to_ico(png: &[u8]) -> Result> { + let image = image::load_from_memory(png).context("decode branding png")?; + let mut icon_dir = ico::IconDir::new(ico::ResourceType::Icon); + for size in ICO_SIZES { + let resized = image.resize_exact(size, size, image::imageops::FilterType::Lanczos3); + let rgba = resized.to_rgba8(); + let entry = ico::IconDirEntry::encode(&ico::IconImage::from_rgba_data( + size, + size, + rgba.into_raw(), + )) + .context("encode icon entry")?; + icon_dir.add_entry(entry); + } + let mut out = Vec::new(); + icon_dir.write(&mut out).context("encode ico")?; + Ok(out) +} diff --git a/app/desktop/windows-setup/common/src/install_icon.rs b/app/desktop/windows-setup/common/src/install_icon.rs new file mode 100644 index 0000000..edd360b --- /dev/null +++ b/app/desktop/windows-setup/common/src/install_icon.rs @@ -0,0 +1,87 @@ +use std::io::Cursor; +use std::path::{Path, PathBuf}; + +/// RGBA8 icon pixels (width × height × 4). +#[derive(Debug, Clone)] +pub struct IconRgba { + pub width: u32, + pub height: u32, + pub pixels: Vec, +} + +/// Parses a Windows `DisplayIcon` registry value (`path` or `path,index`). +pub fn parse_display_icon(raw: &str) -> (PathBuf, i32) { + let raw = raw.trim().trim_matches('"'); + if let Some((path, index_str)) = raw.rsplit_once(',') { + if index_str.chars().all(|c| c.is_ascii_digit()) { + if let Ok(index) = index_str.parse::() { + let path = path.trim().trim_matches('"'); + if !path.is_empty() { + return (PathBuf::from(path), index); + } + } + } + } + (PathBuf::from(raw), 0) +} + +/// Loads the install icon referenced by registry, with optional install-dir fallbacks. +pub fn load_install_display_icon( + display_icon: Option<&str>, + install_dir: &Path, +) -> Option { + if let Some(raw) = display_icon.filter(|s| !s.is_empty()) { + let (path, _index) = parse_display_icon(raw); + if let Some(icon) = try_load_icon_path(&path) { + return Some(icon); + } + } + let ico = install_dir.join("FromChat.ico"); + if ico.is_file() { + return load_ico_file(&ico); + } + None +} + +fn try_load_icon_path(path: &Path) -> Option { + if !path.is_file() { + return None; + } + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default(); + if ext.eq_ignore_ascii_case("ico") { + return load_ico_file(path); + } + if ext.eq_ignore_ascii_case("exe") { + let ico = path.with_extension("ico"); + if ico.is_file() { + return load_ico_file(&ico); + } + } + None +} + +fn load_ico_file(path: &Path) -> Option { + let data = std::fs::read(path).ok()?; + let dir = ico::IconDir::read(&mut Cursor::new(data)).ok()?; + let target = 40u32; + let entry = dir + .entries() + .iter() + .min_by_key(|entry| { + let w = u32::from(entry.width()); + let h = u32::from(entry.height()); + w.abs_diff(target) + h.abs_diff(target) + })?; + let image = entry.decode().ok()?; + let width = u32::from(image.width()); + let height = u32::from(image.height()); + let rgba = image.rgba_data().to_vec(); + Some(IconRgba { + width, + height, + pixels: rgba, + }) +} diff --git a/app/desktop/windows-setup/common/src/lib.rs b/app/desktop/windows-setup/common/src/lib.rs new file mode 100644 index 0000000..229b848 --- /dev/null +++ b/app/desktop/windows-setup/common/src/lib.rs @@ -0,0 +1,17 @@ +//! Shared payload format, extract, and Windows install helpers for FromChat setup. + +mod branding; +mod install_icon; +mod payload; +mod progress; + +#[cfg(windows)] +mod win; + +pub use branding::*; +pub use install_icon::*; +pub use payload::*; +pub use progress::*; + +#[cfg(windows)] +pub use win::*; diff --git a/app/desktop/windows-setup/common/src/payload.rs b/app/desktop/windows-setup/common/src/payload.rs new file mode 100644 index 0000000..22f58c0 --- /dev/null +++ b/app/desktop/windows-setup/common/src/payload.rs @@ -0,0 +1,189 @@ +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs::{self, File}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +pub const SETUP_MAGIC: &[u8; 8] = b"FCHSETUP"; +pub const PORTABLE_MAGIC: &[u8; 8] = b"FCHPORT1"; +pub const VISIBLE_PORTABLE_EXE: &str = "FromChat Portable.exe"; +pub const HIDDEN_APP_EXE: &str = "FromChat.exe"; +pub const PIPE_NAME: &str = r"\\.\pipe\fromchat-setup-helper"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BundleMeta { + pub version: String, + pub mode: BundleMode, + /// Programs and Features registry id (`FromChat` or `FromChat Beta`). + #[serde(default = "default_registration_id")] + pub registration_id: String, +} + +fn default_registration_id() -> String { + "FromChat".to_owned() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BundleMode { + Setup, + Portable, +} + +#[derive(Debug, Clone)] +pub struct EmbeddedBundle { + pub meta: BundleMeta, + pub helper: Vec, + pub launcher: Vec, + pub payload_zstd: Vec, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct Footer { + magic: [u8; 8], + meta_off: u64, + meta_len: u64, + helper_off: u64, + helper_len: u64, + launcher_off: u64, + launcher_len: u64, + payload_off: u64, + payload_len: u64, +} + +impl Footer { + const SIZE: usize = 8 + 8 * 8; + + fn to_bytes(self) -> [u8; Self::SIZE] { + let mut out = [0u8; Self::SIZE]; + out[0..8].copy_from_slice(&self.magic); + let mut i = 8; + for v in [ + self.meta_off, + self.meta_len, + self.helper_off, + self.helper_len, + self.launcher_off, + self.launcher_len, + self.payload_off, + self.payload_len, + ] { + out[i..i + 8].copy_from_slice(&v.to_le_bytes()); + i += 8; + } + out + } + + fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < Self::SIZE { + bail!("footer too short"); + } + let mut magic = [0u8; 8]; + magic.copy_from_slice(&bytes[0..8]); + let mut vals = [0u64; 8]; + for (idx, slot) in vals.iter_mut().enumerate() { + let start = 8 + idx * 8; + *slot = u64::from_le_bytes(bytes[start..start + 8].try_into().unwrap()); + } + Ok(Self { + magic, + meta_off: vals[0], + meta_len: vals[1], + helper_off: vals[2], + helper_len: vals[3], + launcher_off: vals[4], + launcher_len: vals[5], + payload_off: vals[6], + payload_len: vals[7], + }) + } +} + +pub fn append_bundle( + stub: &[u8], + magic: &[u8; 8], + meta: &BundleMeta, + helper: &[u8], + launcher: &[u8], + payload_zstd: &[u8], + out: &Path, +) -> Result<()> { + let meta_bytes = serde_json::to_vec(meta)?; + let mut file = File::create(out).with_context(|| format!("create {}", out.display()))?; + file.write_all(stub)?; + let base = stub.len() as u64; + let meta_off = base; + file.write_all(&meta_bytes)?; + let helper_off = meta_off + meta_bytes.len() as u64; + file.write_all(helper)?; + let launcher_off = helper_off + helper.len() as u64; + file.write_all(launcher)?; + let payload_off = launcher_off + launcher.len() as u64; + file.write_all(payload_zstd)?; + let footer = Footer { + magic: *magic, + meta_off, + meta_len: meta_bytes.len() as u64, + helper_off, + helper_len: helper.len() as u64, + launcher_off, + launcher_len: launcher.len() as u64, + payload_off, + payload_len: payload_zstd.len() as u64, + }; + file.write_all(&footer.to_bytes())?; + Ok(()) +} + +pub fn read_bundle_from_exe(exe: &Path, expected_magic: &[u8; 8]) -> Result { + let mut file = File::open(exe).with_context(|| format!("open {}", exe.display()))?; + let len = file.metadata()?.len(); + if len < Footer::SIZE as u64 { + bail!("executable too small to contain a bundle"); + } + file.seek(SeekFrom::End(-(Footer::SIZE as i64)))?; + let mut footer_buf = [0u8; Footer::SIZE]; + file.read_exact(&mut footer_buf)?; + let footer = Footer::from_bytes(&footer_buf)?; + if &footer.magic != expected_magic { + bail!("bundle magic mismatch"); + } + let mut meta = vec![0u8; footer.meta_len as usize]; + file.seek(SeekFrom::Start(footer.meta_off))?; + file.read_exact(&mut meta)?; + let mut helper = vec![0u8; footer.helper_len as usize]; + file.seek(SeekFrom::Start(footer.helper_off))?; + file.read_exact(&mut helper)?; + let mut launcher = vec![0u8; footer.launcher_len as usize]; + file.seek(SeekFrom::Start(footer.launcher_off))?; + file.read_exact(&mut launcher)?; + let mut payload_zstd = vec![0u8; footer.payload_len as usize]; + file.seek(SeekFrom::Start(footer.payload_off))?; + file.read_exact(&mut payload_zstd)?; + Ok(EmbeddedBundle { + meta: serde_json::from_slice(&meta)?, + helper, + launcher, + payload_zstd, + }) +} + +pub fn compress_directory_zstd(dir: &Path) -> Result> { + let mut builder = tar::Builder::new(Vec::new()); + builder.append_dir_all(".", dir)?; + let tar_bytes = builder.into_inner()?; + Ok(zstd::encode_all(&tar_bytes[..], 10)?) +} + +pub fn extract_zstd_tar(payload_zstd: &[u8], dest: &Path) -> Result<()> { + fs::create_dir_all(dest)?; + let tar_bytes = zstd::decode_all(payload_zstd)?; + let mut archive = tar::Archive::new(&tar_bytes[..]); + archive.unpack(dest)?; + Ok(()) +} + +pub fn current_exe() -> Result { + Ok(std::env::current_exe()?) +} diff --git a/app/desktop/windows-setup/common/src/progress.rs b/app/desktop/windows-setup/common/src/progress.rs new file mode 100644 index 0000000..3e5a2da --- /dev/null +++ b/app/desktop/windows-setup/common/src/progress.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ProgressEvent { + Status { message: String }, + Progress { fraction: f32 }, + Done { launch_path: String }, + Uninstalled, + Error { message: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "cmd", rename_all = "snake_case")] +pub enum HelperCommand { + Install { + dest: String, + version: String, + all_users: bool, + edition: String, + start_menu: bool, + desktop: bool, + payload_path: String, + uninstaller_path: String, + setup_exe_path: String, + }, + Uninstall { + install_dir: String, + all_users: bool, + edition: String, + preserve_setup_exe: Option, + preserve_user_data: bool, + }, + Upgrade { + dest: String, + version: String, + all_users: bool, + edition: String, + payload_path: String, + setup_exe_path: String, + desktop_shortcut: bool, + }, + Ping, +} diff --git a/app/desktop/windows-setup/common/src/win.rs b/app/desktop/windows-setup/common/src/win.rs new file mode 100644 index 0000000..24c600a --- /dev/null +++ b/app/desktop/windows-setup/common/src/win.rs @@ -0,0 +1,713 @@ +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::os::windows::process::CommandExt; +use std::path::{Path, PathBuf}; +use windows::core::{Interface, PCWSTR}; +use windows::Win32::Foundation::{CloseHandle, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE}; +use windows::Win32::Security::{ + GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY, +}; +use windows::Win32::System::Threading::{ + GetCurrentProcess, GetCurrentProcessId, OpenProcess, OpenProcessToken, CREATE_NO_WINDOW, + PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE, TerminateProcess, +}; +use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, +}; +use windows::Win32::System::Threading::QueryFullProcessImageNameW; +use windows::Win32::Storage::FileSystem::{ + CreateFileW, FlushFileBuffers, ReadFile, SetFileAttributesW, WriteFile, FILE_ATTRIBUTE_HIDDEN, + FILE_ATTRIBUTE_NORMAL, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, + OPEN_EXISTING, +}; +use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoTaskMemFree, CoUninitialize, CLSCTX_INPROC_SERVER, + COINIT_APARTMENTTHREADED, IPersistFile, +}; +use windows::Win32::System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, PIPE_READMODE_BYTE, PIPE_TYPE_BYTE, PIPE_WAIT, +}; +use windows::Win32::System::Registry::{ + RegCloseKey, RegCreateKeyExW, RegDeleteTreeW, RegOpenKeyExW, RegQueryValueExW, RegSetValueExW, + HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WRITE, REG_CREATE_KEY_DISPOSITION, + REG_DWORD, REG_OPTION_NON_VOLATILE, REG_SZ, REG_VALUE_TYPE, HKEY, +}; +use windows::Win32::UI::Shell::{ + IShellLinkW, ShellExecuteW, FOLDERID_CommonPrograms, FOLDERID_Desktop, FOLDERID_Programs, + SHGetKnownFolderPath, KF_FLAG_DEFAULT, +}; +use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + +/// Copied setup binary in the install folder (Control Panel uninstaller). +pub const UNINSTALL_SETUP_EXE: &str = "Uninstall FromChat.exe"; + +/// CLI flag: skip welcome and open the uninstall confirmation screen. +pub const UNINSTALL_ARG: &str = "--uninstall"; + +/// CLI flag: silently upgrade the installed copy for this installer's registration id. +pub const UPGRADE_ARG: &str = "--upgrade"; + +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// Release vs beta uninstall registry entries (at most one of each per machine). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FromChatEdition { + Release, + Beta, +} + +impl FromChatEdition { + pub fn registry_key(&self) -> &'static str { + match self { + Self::Release => "FromChat", + Self::Beta => "FromChat Beta", + } + } + + pub fn display_name(&self) -> &'static str { + self.registry_key() + } + + pub fn shortcut_name(&self) -> &'static str { + self.display_name() + } + + pub fn from_registry_key(key: &str) -> Option { + if key.eq_ignore_ascii_case("FromChat") { + Some(Self::Release) + } else if key.eq_ignore_ascii_case("FromChat Beta") { + Some(Self::Beta) + } else { + None + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstalledFromChat { + pub install_dir: PathBuf, + pub all_users: bool, + pub version: Option, + pub edition: FromChatEdition, + pub display_icon: Option, +} + +const KNOWN_REGISTRY_KEYS: &[&str] = &["FromChat", "FromChat Beta"]; + +pub fn uninstall_setup_path(install_dir: &Path) -> PathBuf { + install_dir.join(UNINSTALL_SETUP_EXE) +} + +pub fn uninstall_command_line(uninstaller: &Path) -> String { + format!("\"{}\" {}", uninstaller.to_string_lossy(), UNINSTALL_ARG) +} + +pub fn copy_uninstall_setup(setup_exe: &Path, install_dir: &Path) -> Result { + let dest = uninstall_setup_path(install_dir); + fs::copy(setup_exe, &dest) + .with_context(|| format!("copy setup to {}", dest.display()))?; + Ok(dest) +} + +pub fn directory_size_bytes(root: &Path) -> Result { + if !root.is_dir() { + return Ok(0); + } + let mut total = 0u64; + for entry in fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_dir() { + total = total.saturating_add(directory_size_bytes(&path)?); + } else { + total = total.saturating_add(entry.metadata()?.len()); + } + } + Ok(total) +} + +pub fn detect_all_installed() -> Vec { + let mut out = Vec::new(); + for ®istry_key in KNOWN_REGISTRY_KEYS { + if let Some(installed) = read_install_key(HKEY_LOCAL_MACHINE, true, registry_key) { + push_unique_install(&mut out, installed); + } + if let Some(installed) = read_install_key(HKEY_CURRENT_USER, false, registry_key) { + push_unique_install(&mut out, installed); + } + } + out.truncate(2); + out +} + +pub fn detect_installed() -> Option { + detect_all_installed().into_iter().next() +} + +/// Looks up a single install matching the uninstall registry id for this installer build. +pub fn find_installed_by_registration_id(registration_id: &str) -> Option { + if FromChatEdition::from_registry_key(registration_id).is_none() { + return None; + } + read_install_key(HKEY_LOCAL_MACHINE, true, registration_id) + .or_else(|| read_install_key(HKEY_CURRENT_USER, false, registration_id)) +} + +fn push_unique_install(out: &mut Vec, installed: InstalledFromChat) { + if out + .iter() + .any(|entry| entry.install_dir == installed.install_dir && entry.edition == installed.edition) + { + return; + } + out.push(installed); +} + +fn read_install_key(root: HKEY, all_users: bool, registry_key: &str) -> Option { + let edition = FromChatEdition::from_registry_key(registry_key)?; + unsafe { + let sub = wide(&format!( + r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{registry_key}" + )); + let mut key = HKEY::default(); + RegOpenKeyExW(root, PCWSTR(sub.as_ptr()), 0, KEY_READ, &mut key) + .ok() + .ok()?; + let install_dir = read_reg_string(key, "InstallLocation").ok()?; + let version = read_reg_string(key, "DisplayVersion").ok(); + let display_icon = read_reg_string(key, "DisplayIcon").ok(); + let _ = RegCloseKey(key).ok(); + if install_dir.is_empty() { + return None; + } + let install_dir = PathBuf::from(install_dir); + if !install_dir.is_dir() { + return None; + } + Some(InstalledFromChat { + install_dir, + all_users, + version, + edition, + display_icon, + }) + } +} + +pub fn uninstall_fromchat( + installed: &InstalledFromChat, + preserve_exe: Option<&Path>, + preserve_user_data: bool, +) -> Result<()> { + init_com()?; + terminate_processes_in_install_dir(&installed.install_dir)?; + let shortcut = format!("{}.lnk", installed.edition.shortcut_name()); + let programs = programs_folder(installed.all_users)?; + let _ = fs::remove_file(programs.join(&shortcut)); + let desk = desktop_folder()?; + let _ = fs::remove_file(desk.join(&shortcut)); + delete_uninstall_registry(installed.all_users, installed.edition.registry_key())?; + if installed.install_dir.is_dir() { + let auto_preserve = std::env::current_exe() + .ok() + .filter(|exe| exe.starts_with(&installed.install_dir)); + let effective_preserve = preserve_exe.or(auto_preserve.as_deref()); + remove_install_dir_best_effort(&installed.install_dir, effective_preserve)?; + if installed.install_dir.exists() { + schedule_remove_dir_after_exit(&installed.install_dir)?; + } + } + if !preserve_user_data { + clear_user_data(&installed.install_dir)?; + } + Ok(()) +} + +/// Wipes session, settings, cache, and portable data for this installation. +pub fn clear_user_data(install_dir: &Path) -> Result<()> { + let portable_data = install_dir.join("fromchat-data"); + if portable_data.is_dir() { + let _ = fs::remove_dir_all(&portable_data); + } + if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") { + let app_data = PathBuf::from(local_app_data).join("FromChat"); + if app_data.is_dir() { + let _ = fs::remove_dir_all(&app_data); + } + } + clear_java_prefs_node(r"Software\JavaSoft\Prefs\ru\fromchat\settings")?; + clear_java_prefs_node(r"Software\JavaSoft\Prefs\ru\fromchat\secure")?; + Ok(()) +} + +fn clear_java_prefs_node(subkey: &str) -> Result<()> { + let sub = wide(subkey); + unsafe { + let _ = RegDeleteTreeW(HKEY_CURRENT_USER, PCWSTR(sub.as_ptr())); + } + Ok(()) +} + +pub fn wipe_install_dir(install_dir: &Path, preserve_exe: Option<&Path>) -> Result<()> { + terminate_processes_in_install_dir(install_dir)?; + if install_dir.is_dir() { + remove_install_dir_best_effort(install_dir, preserve_exe)?; + } + Ok(()) +} + +fn remove_install_dir_best_effort(install_dir: &Path, preserve_exe: Option<&Path>) -> Result<()> { + for entry in fs::read_dir(install_dir)? { + let entry = entry?; + let path = entry.path(); + if preserve_exe.is_some_and(|exe| exe == path) { + continue; + } + if entry.file_type()?.is_dir() { + let _ = fs::remove_dir_all(&path); + } else { + let _ = fs::remove_file(&path); + } + } + Ok(()) +} + +fn init_com() -> Result<()> { + unsafe { + CoInitializeEx(None, COINIT_APARTMENTTHREADED).ok()?; + } + Ok(()) +} + +pub fn terminate_processes_in_install_dir(install_dir: &Path) -> Result<()> { + let install_dir = normalize_path(install_dir); + let self_pid = unsafe { GetCurrentProcessId() }; + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)? }; + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let mut found = unsafe { Process32FirstW(snapshot, &mut entry).is_ok() }; + while found { + let pid = entry.th32ProcessID; + if pid != self_pid { + if let Ok(image) = process_image_path(pid) { + let image = normalize_path(&image); + if image.starts_with(&install_dir) { + let _ = terminate_process(pid); + } + } + } + found = unsafe { Process32NextW(snapshot, &mut entry).is_ok() }; + } + unsafe { + let _ = CloseHandle(snapshot); + } + std::thread::sleep(std::time::Duration::from_millis(400)); + Ok(()) +} + +fn process_image_path(pid: u32) -> Result { + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid)?; + let mut buf = vec![0u16; 32_768]; + let mut size = buf.len() as u32; + QueryFullProcessImageNameW( + handle, + windows::Win32::System::Threading::PROCESS_NAME_WIN32, + windows::core::PWSTR(buf.as_mut_ptr()), + &mut size, + )?; + let _ = CloseHandle(handle); + let path = String::from_utf16_lossy(&buf[..size as usize]); + Ok(PathBuf::from(path)) + } +} + +fn terminate_process(pid: u32) -> Result<()> { + unsafe { + let handle = OpenProcess(PROCESS_TERMINATE, false, pid)?; + TerminateProcess(handle, 1)?; + let _ = CloseHandle(handle); + } + Ok(()) +} + +fn normalize_path(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +fn schedule_remove_dir_after_exit(install_dir: &Path) -> Result<()> { + let dir = install_dir.to_string_lossy().replace('\"', ""); + let script = format!("timeout /t 2 /nobreak > nul & rd /s /q \"{dir}\""); + std::process::Command::new("cmd") + .args(["/C", &script]) + .creation_flags(CREATE_NO_WINDOW.0) + .spawn() + .context("schedule install dir removal")?; + Ok(()) +} + +pub fn set_hidden(path: &Path, hidden: bool) -> Result<()> { + let w = wide(&path.to_string_lossy()); + let attr = if hidden { + FILE_FLAGS_AND_ATTRIBUTES(FILE_ATTRIBUTE_HIDDEN.0) + } else { + FILE_FLAGS_AND_ATTRIBUTES(FILE_ATTRIBUTE_NORMAL.0) + }; + unsafe { + SetFileAttributesW(PCWSTR(w.as_ptr()), attr) + .ok() + .with_context(|| format!("SetFileAttributes {}", path.display()))?; + } + Ok(()) +} + +pub fn hide_all_except(root: &Path, visible_name: &str) -> Result<()> { + for entry in fs::read_dir(root)? { + let entry = entry?; + let name_str = entry.file_name().to_string_lossy().into_owned(); + if name_str.eq_ignore_ascii_case(visible_name) { + set_hidden(&entry.path(), false)?; + continue; + } + set_hidden(&entry.path(), true)?; + if entry.file_type()?.is_dir() { + hide_tree(&entry.path())?; + } + } + Ok(()) +} + +fn hide_tree(dir: &Path) -> Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + set_hidden(&entry.path(), true)?; + if entry.file_type()?.is_dir() { + hide_tree(&entry.path())?; + } + } + Ok(()) +} + +pub fn known_folder(id: windows::core::GUID) -> Result { + unsafe { + let pwstr = SHGetKnownFolderPath(&id, KF_FLAG_DEFAULT, None)?; + let path = pwstr.to_string()?; + CoTaskMemFree(Some(pwstr.0 as *const _ as *mut _)); + Ok(PathBuf::from(path)) + } +} + +pub fn create_shortcut( + link_path: &Path, + target: &Path, + working_dir: &Path, + description: &str, + icon: &Path, +) -> Result<()> { + unsafe { + CoInitializeEx(None, COINIT_APARTMENTTHREADED).ok()?; + // CLSID_ShellLink + let clsid = windows::core::GUID::from_u128(0x0002_1401_0000_0000_C000_000000000046); + let link: IShellLinkW = CoCreateInstance(&clsid, None, CLSCTX_INPROC_SERVER)?; + let target_w = wide(&target.to_string_lossy()); + let work_w = wide(&working_dir.to_string_lossy()); + let desc_w = wide(description); + let icon_w = wide(&icon.to_string_lossy()); + link.SetPath(PCWSTR(target_w.as_ptr()))?; + link.SetWorkingDirectory(PCWSTR(work_w.as_ptr()))?; + link.SetDescription(PCWSTR(desc_w.as_ptr()))?; + link.SetIconLocation(PCWSTR(icon_w.as_ptr()), 0)?; + let persist: IPersistFile = link.cast()?; + let link_w = wide(&link_path.to_string_lossy()); + persist.Save(PCWSTR(link_w.as_ptr()), true)?; + CoUninitialize(); + } + Ok(()) +} + +pub fn write_uninstall_registry( + all_users: bool, + edition: FromChatEdition, + version: &str, + install_dir: &Path, + uninstall_exe: &Path, + display_icon: &Path, +) -> Result<()> { + let root = if all_users { + HKEY_LOCAL_MACHINE + } else { + HKEY_CURRENT_USER + }; + let sub = wide(&format!( + r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}", + edition.registry_key() + )); + unsafe { + let mut key = HKEY::default(); + let mut disposition = REG_CREATE_KEY_DISPOSITION::default(); + RegCreateKeyExW( + root, + PCWSTR(sub.as_ptr()), + 0, + None, + REG_OPTION_NON_VOLATILE, + KEY_WRITE, + None, + &mut key, + Some(&mut disposition), + ) + .ok()?; + set_reg_string(key, "DisplayName", edition.display_name())?; + set_reg_string(key, "DisplayVersion", version)?; + set_reg_string(key, "Publisher", "FromChat")?; + set_reg_string(key, "InstallLocation", &install_dir.to_string_lossy())?; + set_reg_string(key, "DisplayIcon", &display_icon.to_string_lossy())?; + set_reg_string( + key, + "UninstallString", + &uninstall_command_line(uninstall_exe), + )?; + let size_kb = (directory_size_bytes(install_dir)? / 1024).min(u32::MAX as u64) as u32; + set_reg_dword(key, "EstimatedSize", size_kb)?; + RegCloseKey(key).ok()?; + } + Ok(()) +} + +unsafe fn read_reg_string(key: HKEY, name: &str) -> Result { + let name_w = wide(name); + let mut kind = REG_VALUE_TYPE::default(); + let mut len = 0u32; + RegQueryValueExW(key, PCWSTR(name_w.as_ptr()), None, Some(&mut kind), None, Some(&mut len)) + .ok()?; + if len < 2 { + return Ok(String::new()); + } + let mut buf = vec![0u8; len as usize]; + RegQueryValueExW( + key, + PCWSTR(name_w.as_ptr()), + None, + Some(&mut kind), + Some(buf.as_mut_ptr()), + Some(&mut len), + ) + .ok()?; + let wide_len = (len as usize / 2).saturating_sub(1); + let chars: Vec = buf + .chunks_exact(2) + .take(wide_len) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + Ok(String::from_utf16_lossy(&chars)) +} + +unsafe fn set_reg_dword(key: HKEY, name: &str, value: u32) -> Result<()> { + let name_w = wide(name); + let bytes = value.to_le_bytes(); + RegSetValueExW(key, PCWSTR(name_w.as_ptr()), 0, REG_DWORD, Some(&bytes)).ok()?; + Ok(()) +} + +unsafe fn set_reg_string(key: HKEY, name: &str, value: &str) -> Result<()> { + let name_w = wide(name); + let value_w = wide(value); + let bytes_len = value_w.len() * 2; + let bytes = std::slice::from_raw_parts(value_w.as_ptr() as *const u8, bytes_len); + RegSetValueExW(key, PCWSTR(name_w.as_ptr()), 0, REG_SZ, Some(bytes)).ok()?; + Ok(()) +} + +pub fn delete_uninstall_registry(all_users: bool, registry_key: &str) -> Result<()> { + let root = if all_users { + HKEY_LOCAL_MACHINE + } else { + HKEY_CURRENT_USER + }; + let sub = wide(&format!( + r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{registry_key}" + )); + unsafe { + let _ = RegDeleteTreeW(root, PCWSTR(sub.as_ptr())); + } + Ok(()) +} + +pub fn is_elevated() -> bool { + unsafe { + let mut token = HANDLE::default(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() { + return false; + } + let mut elevation = TOKEN_ELEVATION::default(); + let mut returned = 0u32; + let ok = GetTokenInformation( + token, + TokenElevation, + Some(&mut elevation as *mut _ as *mut _), + std::mem::size_of::() as u32, + &mut returned, + ) + .is_ok(); + let _ = CloseHandle(token); + ok && elevation.TokenIsElevated != 0 + } +} + +pub fn elevate_helper(helper_path: &Path, args: &str) -> Result<()> { + let file = wide(&helper_path.to_string_lossy()); + let params = wide(args); + let op = wide("runas"); + unsafe { + let ret = ShellExecuteW( + None, + PCWSTR(op.as_ptr()), + PCWSTR(file.as_ptr()), + PCWSTR(params.as_ptr()), + None, + SW_SHOWNORMAL, + ); + if (ret.0 as usize) <= 32 { + bail!("ShellExecute elevation failed ({})", ret.0 as usize); + } + } + Ok(()) +} + +pub fn default_install_dir(all_users: bool) -> Result { + if all_users { + let program_files = + std::env::var("ProgramFiles").unwrap_or_else(|_| r"C:\Program Files".into()); + Ok(PathBuf::from(program_files).join("FromChat")) + } else { + let local = std::env::var("LOCALAPPDATA").context("LOCALAPPDATA")?; + Ok(PathBuf::from(local).join("Programs").join("FromChat")) + } +} + +pub fn programs_folder(all_users: bool) -> Result { + if all_users { + known_folder(FOLDERID_CommonPrograms) + } else { + known_folder(FOLDERID_Programs) + } +} + +pub fn desktop_folder() -> Result { + known_folder(FOLDERID_Desktop) +} + +pub struct NamedPipeServer { + handle: HANDLE, +} + +#[cfg(windows)] +// HANDLE is thread-safe to use from the owning pipe server after `accept`. +unsafe impl Send for NamedPipeServer {} + +impl NamedPipeServer { + pub fn create(name: &str) -> Result { + let w = wide(name); + unsafe { + let handle = CreateNamedPipeW( + PCWSTR(w.as_ptr()), + windows::Win32::Storage::FileSystem::FILE_FLAGS_AND_ATTRIBUTES(0x00000003), + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + 1, + 64 * 1024, + 64 * 1024, + 0, + None, + ); + if handle == INVALID_HANDLE_VALUE { + bail!("CreateNamedPipe failed"); + } + Ok(Self { handle }) + } + } + + pub fn accept(&self) -> Result<()> { + unsafe { + ConnectNamedPipe(self.handle, None) + .ok() + .context("ConnectNamedPipe")?; + } + Ok(()) + } + + pub fn handle(&self) -> HANDLE { + self.handle + } +} + +impl Drop for NamedPipeServer { + fn drop(&mut self) { + unsafe { + let _ = DisconnectNamedPipe(self.handle); + let _ = CloseHandle(self.handle); + } + } +} + +pub fn pipe_read_line(handle: HANDLE) -> Result { + let mut buf = Vec::new(); + let mut byte = [0u8; 1]; + loop { + let mut read = 0u32; + unsafe { + ReadFile(handle, Some(&mut byte), Some(&mut read), None) + .ok() + .context("ReadFile")?; + } + if read == 0 { + break; + } + if byte[0] == b'\n' { + break; + } + if byte[0] != b'\r' { + buf.push(byte[0]); + } + } + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + +pub fn pipe_write_line(handle: HANDLE, line: &str) -> Result<()> { + let mut data = line.as_bytes().to_vec(); + data.push(b'\n'); + let mut written = 0u32; + unsafe { + WriteFile(handle, Some(&data), Some(&mut written), None) + .ok() + .context("WriteFile")?; + FlushFileBuffers(handle) + .ok() + .context("FlushFileBuffers")?; + } + Ok(()) +} + +pub fn open_pipe_client(name: &str) -> Result { + let w = wide(name); + unsafe { + let handle = CreateFileW( + PCWSTR(w.as_ptr()), + GENERIC_READ.0 | GENERIC_WRITE.0, + FILE_SHARE_READ | FILE_SHARE_WRITE, + None, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + None, + )?; + Ok(handle) + } +} + +/// Named pipe client/server handle (re-exported for helper binary). +pub type PipeHandle = HANDLE; diff --git a/app/desktop/windows-setup/helper/Cargo.toml b/app/desktop/windows-setup/helper/Cargo.toml new file mode 100644 index 0000000..5dc6516 --- /dev/null +++ b/app/desktop/windows-setup/helper/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "fromchat-setup-helper" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "fromchat-setup-helper" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +fromchat-setup-common.workspace = true +serde_json.workspace = true + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.58", features = ["Win32_System_Com"] } + +[build-dependencies] +winres = "0.1" +image = { version = "0.25", default-features = false, features = ["png"] } +ico = "0.4" diff --git a/app/desktop/windows-setup/helper/build.rs b/app/desktop/windows-setup/helper/build.rs new file mode 100644 index 0000000..bd9fd6d --- /dev/null +++ b/app/desktop/windows-setup/helper/build.rs @@ -0,0 +1,37 @@ +fn main() { + embed_windows_icon("../assets/app_window_icon.png"); +} + +fn embed_windows_icon(png_relative: &str) { + if std::env::var("CARGO_CFG_TARGET_OS").ok().as_deref() != Some("windows") { + return; + } + let manifest_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let png_path = manifest_dir.join(png_relative); + let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let ico_path = out_dir.join("app.ico"); + png_to_ico(&png_path, &ico_path); + let mut res = winres::WindowsResource::new(); + res.set_icon(ico_path.to_str().expect("ico path utf-8")); + if let Err(error) = res.compile() { + panic!("winres compile failed: {error}"); + } +} + +fn png_to_ico(png_path: &std::path::Path, ico_path: &std::path::Path) { + let image = image::open(png_path).expect("open icon png"); + let mut icon_dir = ico::IconDir::new(ico::ResourceType::Icon); + for size in [16u32, 32, 48, 256] { + let resized = image.resize_exact(size, size, image::imageops::FilterType::Lanczos3); + let rgba = resized.to_rgba8(); + let entry = ico::IconDirEntry::encode(&ico::IconImage::from_rgba_data( + size, + size, + rgba.into_raw(), + )) + .expect("encode icon entry"); + icon_dir.add_entry(entry); + } + let file = std::fs::File::create(ico_path).expect("create ico"); + icon_dir.write(file).expect("write ico"); +} diff --git a/app/desktop/windows-setup/helper/src/main.rs b/app/desktop/windows-setup/helper/src/main.rs new file mode 100644 index 0000000..a07a566 --- /dev/null +++ b/app/desktop/windows-setup/helper/src/main.rs @@ -0,0 +1,306 @@ +#![windows_subsystem = "windows"] + +use anyhow::{Context, Result}; +use clap::Parser; +use fromchat_setup_common::{ + copy_uninstall_setup, create_shortcut, desktop_folder, extract_zstd_tar, open_pipe_client, + pipe_write_line, programs_folder, uninstall_fromchat, wipe_install_dir, write_uninstall_registry, + FromChatEdition, HelperCommand, InstalledFromChat, ProgressEvent, +}; +use std::fs; +use std::path::{Path, PathBuf}; +use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_APARTMENTTHREADED}; + +#[derive(Parser)] +struct Args { + #[arg(long, default_value = r"\\.\pipe\fromchat-setup-helper")] + pipe: String, +} + +fn main() { + if let Err(e) = run() { + eprintln!("{e:#}"); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + unsafe { + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + } + let result = run_inner(); + unsafe { + CoUninitialize(); + } + result +} + +fn run_inner() -> Result<()> { + let args = Args::parse(); + let handle = open_pipe_client(&args.pipe).context("connect to setup UI pipe")?; + let line = fromchat_setup_common::pipe_read_line(handle)?; + let cmd: HelperCommand = serde_json::from_str(&line)?; + if let Err(error) = dispatch(handle, cmd) { + let _ = pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Error { + message: format!("{error:#}"), + })?, + ); + } + Ok(()) +} + +fn dispatch(handle: fromchat_setup_common::PipeHandle, cmd: HelperCommand) -> Result<()> { + match cmd { + HelperCommand::Ping => { + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Status { + message: "pong".into(), + })?, + )?; + } + HelperCommand::Install { + dest, + version, + all_users, + edition, + start_menu, + desktop, + payload_path, + uninstaller_path, + setup_exe_path, + } => { + let dest = PathBuf::from(dest); + let uninstaller_path = PathBuf::from(uninstaller_path); + let setup_exe_path = PathBuf::from(setup_exe_path); + let edition = parse_edition(&edition)?; + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Status { + message: "Copying runtime…".into(), + })?, + )?; + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Progress { fraction: 0.2 })?, + )?; + let payload = fs::read(&payload_path)?; + let _ = fs::remove_dir_all(&dest); + fs::create_dir_all(&dest)?; + extract_zstd_tar(&payload, &dest)?; + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Progress { fraction: 0.7 })?, + )?; + let app_exe = find_app_exe(&dest)?; + let icon = fromchat_setup_common::write_install_icon(&dest)?; + copy_uninstall_setup(&setup_exe_path, &dest)?; + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Status { + message: "Registering…".into(), + })?, + )?; + write_uninstall_registry( + all_users, + edition, + &version, + &dest, + &uninstaller_path, + &icon, + )?; + let shortcut = format!("{}.lnk", edition.shortcut_name()); + if start_menu { + let link = programs_folder(all_users)?.join(&shortcut); + create_shortcut( + &link, + &app_exe, + &dest, + edition.display_name(), + &icon, + )?; + } + if desktop { + let link = desktop_folder()?.join(&shortcut); + create_shortcut( + &link, + &app_exe, + &dest, + edition.display_name(), + &icon, + )?; + } + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Progress { fraction: 1.0 })?, + )?; + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Done { + launch_path: app_exe.to_string_lossy().into_owned(), + })?, + )?; + } + HelperCommand::Uninstall { + install_dir, + all_users, + edition, + preserve_setup_exe, + preserve_user_data, + } => { + let installed = InstalledFromChat { + install_dir: PathBuf::from(install_dir), + all_users, + version: None, + edition: parse_edition(&edition)?, + display_icon: None, + }; + let preserve = preserve_setup_exe.as_deref().map(Path::new); + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Status { + message: "Удаление файлов…".into(), + })?, + )?; + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Progress { fraction: 0.5 })?, + )?; + uninstall_fromchat(&installed, preserve, preserve_user_data)?; + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Progress { fraction: 1.0 })?, + )?; + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Uninstalled)?, + )?; + } + HelperCommand::Upgrade { + dest, + version, + all_users, + edition, + payload_path, + setup_exe_path, + desktop_shortcut, + } => { + let dest = PathBuf::from(dest); + let setup_exe_path = PathBuf::from(setup_exe_path); + let edition = parse_edition(&edition)?; + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Status { + message: "Удаление старых файлов…".into(), + })?, + )?; + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Progress { fraction: 0.15 })?, + )?; + wipe_install_dir(&dest, None)?; + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Status { + message: "Распаковка…".into(), + })?, + )?; + let payload = fs::read(&payload_path)?; + fs::create_dir_all(&dest)?; + extract_zstd_tar(&payload, &dest)?; + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Progress { fraction: 0.7 })?, + )?; + let app_exe = find_app_exe(&dest)?; + let icon = fromchat_setup_common::write_install_icon(&dest)?; + let uninstaller = copy_uninstall_setup(&setup_exe_path, &dest)?; + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Status { + message: "Регистрация…".into(), + })?, + )?; + write_uninstall_registry( + all_users, + edition, + &version, + &dest, + &uninstaller, + &icon, + )?; + let shortcut = format!("{}.lnk", edition.shortcut_name()); + let link = programs_folder(all_users)?.join(&shortcut); + create_shortcut( + &link, + &app_exe, + &dest, + edition.display_name(), + &icon, + )?; + if desktop_shortcut { + let link = desktop_folder()?.join(&shortcut); + create_shortcut(&link, &app_exe, &dest, edition.display_name(), &icon)?; + } + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Progress { fraction: 1.0 })?, + )?; + pipe_write_line( + handle, + &serde_json::to_string(&ProgressEvent::Done { + launch_path: app_exe.to_string_lossy().into_owned(), + })?, + )?; + } + } + Ok(()) +} + +fn parse_edition(raw: &str) -> Result { + match raw { + "release" => Ok(FromChatEdition::Release), + "beta" => Ok(FromChatEdition::Beta), + _ => anyhow::bail!("unknown edition {raw}"), + } +} + +fn find_app_exe(dest: &PathBuf) -> Result { + for c in [ + dest.join("FromChat.exe"), + dest.join("bin").join("FromChat.exe"), + ] { + if c.is_file() { + return Ok(c); + } + } + for entry in walk(dest)? { + if entry + .file_name() + .is_some_and(|n| n.eq_ignore_ascii_case("FromChat.exe")) + { + return Ok(entry); + } + } + anyhow::bail!("FromChat.exe not found"); +} + +fn walk(root: &PathBuf) -> Result> { + let mut out = Vec::new(); + fn rec(dir: &PathBuf, out: &mut Vec) -> Result<()> { + for e in fs::read_dir(dir)? { + let e = e?; + let p = e.path(); + if e.file_type()?.is_dir() { + rec(&p, out)?; + } else { + out.push(p); + } + } + Ok(()) + } + rec(root, &mut out)?; + Ok(out) +} diff --git a/app/desktop/windows-setup/pack/Cargo.toml b/app/desktop/windows-setup/pack/Cargo.toml new file mode 100644 index 0000000..a3373ff --- /dev/null +++ b/app/desktop/windows-setup/pack/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "fromchat-pack" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "fromchat-pack" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +fromchat-setup-common.workspace = true diff --git a/app/desktop/windows-setup/pack/src/main.rs b/app/desktop/windows-setup/pack/src/main.rs new file mode 100644 index 0000000..7bcb2bd --- /dev/null +++ b/app/desktop/windows-setup/pack/src/main.rs @@ -0,0 +1,56 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use fromchat_setup_common::{ + append_bundle, compress_directory_zstd, BundleMeta, BundleMode, SETUP_MAGIC, +}; +use std::fs; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +struct Args { + #[arg(long)] + app_image: PathBuf, + #[arg(long)] + version: String, + /// Uninstall registry id baked into the installer (`FromChat` or `FromChat Beta`). + #[arg(long, default_value = "FromChat")] + registration_id: String, + #[arg(long)] + setup_out: PathBuf, + #[arg(long)] + setup_bin: PathBuf, + #[arg(long)] + helper_bin: PathBuf, + #[arg(long)] + launcher_bin: PathBuf, +} + +fn main() -> Result<()> { + let args = Args::parse(); + let payload = compress_directory_zstd(&args.app_image) + .with_context(|| format!("compress {}", args.app_image.display()))?; + let setup_stub = fs::read(&args.setup_bin)?; + let helper = fs::read(&args.helper_bin)?; + let launcher = fs::read(&args.launcher_bin)?; + + if let Some(parent) = args.setup_out.parent() { + fs::create_dir_all(parent)?; + } + + append_bundle( + &setup_stub, + SETUP_MAGIC, + &BundleMeta { + version: args.version, + mode: BundleMode::Setup, + registration_id: args.registration_id, + }, + &helper, + &launcher, + &payload, + &args.setup_out, + )?; + + println!("Wrote {}", args.setup_out.display()); + Ok(()) +} diff --git a/app/desktop/windows-setup/portable-launcher/Cargo.toml b/app/desktop/windows-setup/portable-launcher/Cargo.toml new file mode 100644 index 0000000..a86b011 --- /dev/null +++ b/app/desktop/windows-setup/portable-launcher/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "fromchat-portable-launcher" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "fromchat-portable-launcher" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +fromchat-setup-common.workspace = true + +[build-dependencies] +winres = "0.1" +image = { version = "0.25", default-features = false, features = ["png"] } +ico = "0.4" diff --git a/app/desktop/windows-setup/portable-launcher/build.rs b/app/desktop/windows-setup/portable-launcher/build.rs new file mode 100644 index 0000000..bd9fd6d --- /dev/null +++ b/app/desktop/windows-setup/portable-launcher/build.rs @@ -0,0 +1,37 @@ +fn main() { + embed_windows_icon("../assets/app_window_icon.png"); +} + +fn embed_windows_icon(png_relative: &str) { + if std::env::var("CARGO_CFG_TARGET_OS").ok().as_deref() != Some("windows") { + return; + } + let manifest_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let png_path = manifest_dir.join(png_relative); + let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let ico_path = out_dir.join("app.ico"); + png_to_ico(&png_path, &ico_path); + let mut res = winres::WindowsResource::new(); + res.set_icon(ico_path.to_str().expect("ico path utf-8")); + if let Err(error) = res.compile() { + panic!("winres compile failed: {error}"); + } +} + +fn png_to_ico(png_path: &std::path::Path, ico_path: &std::path::Path) { + let image = image::open(png_path).expect("open icon png"); + let mut icon_dir = ico::IconDir::new(ico::ResourceType::Icon); + for size in [16u32, 32, 48, 256] { + let resized = image.resize_exact(size, size, image::imageops::FilterType::Lanczos3); + let rgba = resized.to_rgba8(); + let entry = ico::IconDirEntry::encode(&ico::IconImage::from_rgba_data( + size, + size, + rgba.into_raw(), + )) + .expect("encode icon entry"); + icon_dir.add_entry(entry); + } + let file = std::fs::File::create(ico_path).expect("create ico"); + icon_dir.write(file).expect("write ico"); +} diff --git a/app/desktop/windows-setup/portable-launcher/src/main.rs b/app/desktop/windows-setup/portable-launcher/src/main.rs new file mode 100644 index 0000000..ec53b99 --- /dev/null +++ b/app/desktop/windows-setup/portable-launcher/src/main.rs @@ -0,0 +1,115 @@ +//! Visible portable entrypoint / SFX: +//! - If this EXE embeds a portable payload, extract once beside itself then relaunch. +//! - Otherwise launch the hidden FromChat.exe with portable JVM flags. + +#![windows_subsystem = "windows"] + +use anyhow::{bail, Context, Result}; +use fromchat_setup_common::{extract_zstd_tar, read_bundle_from_exe, PORTABLE_MAGIC, VISIBLE_PORTABLE_EXE}; +#[cfg(windows)] +use fromchat_setup_common::{hide_all_except, set_hidden}; +use std::env; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +fn main() { + if let Err(e) = run() { + eprintln!("FromChat Portable: {e:#}"); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let exe = env::current_exe()?; + let dir = exe + .parent() + .map(|p| p.to_path_buf()) + .context("portable exe has no parent directory")?; + + // Standalone portable download: extract into a sibling folder on first run. + if let Ok(bundle) = read_bundle_from_exe(&exe, PORTABLE_MAGIC) { + let target = dir.join("FromChat"); + let marker = target.join(".fromchat-extracted"); + if !marker.is_file() { + std::fs::create_dir_all(&target)?; + extract_zstd_tar(&bundle.payload_zstd, &target)?; + let launcher_dest = target.join(VISIBLE_PORTABLE_EXE); + // Prefer embedded launcher bytes; fall back to copying ourselves without payload. + if !bundle.launcher.is_empty() { + std::fs::write(&launcher_dest, &bundle.launcher)?; + } else { + std::fs::copy(&exe, &launcher_dest)?; + } + #[cfg(windows)] + { + hide_all_except(&target, VISIBLE_PORTABLE_EXE)?; + let data = target.join("fromchat-data"); + std::fs::create_dir_all(&data)?; + set_hidden(&data, true)?; + } + std::fs::write(&marker, b"1\n")?; + #[cfg(windows)] + set_hidden(&marker, true)?; + } + let launcher = target.join(VISIBLE_PORTABLE_EXE); + if launcher.is_file() && launcher.canonicalize().ok() != exe.canonicalize().ok() { + let status = Command::new(&launcher) + .args(env::args().skip(1)) + .status()?; + std::process::exit(status.code().unwrap_or(1)); + } + } + + let app = find_app_exe(&dir)?; + let mut cmd = Command::new(&app); + cmd.current_dir(&dir) + .args(env::args().skip(1)) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + cmd.env( + "JAVA_TOOL_OPTIONS", + format!( + "-Dfromchat.portable=true -Dfromchat.exe.dir={}", + dir.display() + ), + ); + let status = cmd.status().with_context(|| format!("spawn {}", app.display()))?; + std::process::exit(status.code().unwrap_or(1)); +} + +fn find_app_exe(dir: &PathBuf) -> Result { + for name in ["FromChat.exe", "bin/FromChat.exe"] { + let p = dir.join(name); + if p.is_file() { + return Ok(p); + } + } + for entry in walk(dir)? { + if entry + .file_name() + .is_some_and(|n| n.eq_ignore_ascii_case("FromChat.exe")) + { + return Ok(entry); + } + } + bail!("Hidden FromChat.exe not found next to the portable launcher"); +} + +fn walk(root: &PathBuf) -> Result> { + let mut out = Vec::new(); + fn rec(dir: &PathBuf, out: &mut Vec) -> Result<()> { + for e in std::fs::read_dir(dir)? { + let e = e?; + let p = e.path(); + if e.file_type()?.is_dir() { + rec(&p, out)?; + } else { + out.push(p); + } + } + Ok(()) + } + rec(root, &mut out)?; + Ok(out) +} diff --git a/app/desktop/windows-setup/scripts/export-welcome-brand-bg.mjs b/app/desktop/windows-setup/scripts/export-welcome-brand-bg.mjs new file mode 100644 index 0000000..963af82 --- /dev/null +++ b/app/desktop/windows-setup/scripts/export-welcome-brand-bg.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +/** + * Rasterize welcome_brand_bg.html with the same Chromium CSS as the macOS DMG. + * Run from app/desktop/dmg-background (needs its playwright install): + * node ../windows-setup/scripts/export-welcome-brand-bg.mjs + */ +import { createRequire } from "node:module"; +import { createServer } from "node:http"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, "../assets"); +const OUT = path.join(ROOT, "welcome_brand_bg.png"); +const require = createRequire(path.resolve(__dirname, "../../dmg-background/package.json")); +const { chromium } = require("playwright"); + +const MIME = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".png": "image/png", +}; + +function startStaticServer() { + return new Promise((resolve, reject) => { + const server = createServer(async (req, res) => { + try { + const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]); + const rel = urlPath === "/" ? "/welcome_brand_bg.html" : urlPath; + const filePath = path.normalize(path.join(ROOT, rel)); + if (!filePath.startsWith(ROOT)) { + res.writeHead(403).end("Forbidden"); + return; + } + const data = await readFile(filePath); + const ext = path.extname(filePath).toLowerCase(); + res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream" }); + res.end(data); + } catch { + res.writeHead(404).end("Not found"); + } + }); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (!addr || typeof addr === "string") { + reject(new Error("bind failed")); + return; + } + resolve({ server, port: addr.port }); + }); + server.on("error", reject); + }); +} + +const { server, port } = await startStaticServer(); +const browser = await chromium.launch({ headless: true }); +try { + const page = await browser.newPage({ + viewport: { width: 480, height: 520 }, + deviceScaleFactor: 2, + }); + await page.goto(`http://127.0.0.1:${port}/welcome_brand_bg.html`, { + waitUntil: "networkidle", + }); + await page.waitForTimeout(200); + const buf = await page.locator("#bg").screenshot({ type: "png" }); + await writeFile(OUT, buf); + console.log(`wrote ${OUT} (${buf.length} bytes)`); +} finally { + await browser.close(); + server.close(); +} diff --git a/app/desktop/windows-setup/setup/Cargo.toml b/app/desktop/windows-setup/setup/Cargo.toml new file mode 100644 index 0000000..863eb77 --- /dev/null +++ b/app/desktop/windows-setup/setup/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "fromchat-setup" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "fromchat-setup" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +eframe.workspace = true +egui.workspace = true +fromchat-setup-common.workspace = true +serde_json.workspace = true +image = { version = "0.25", default-features = false, features = ["png"] } +earcutr = "0.4" +raw-window-handle = "0.6" + +[build-dependencies] +winres = "0.1" +image = { version = "0.25", default-features = false, features = ["png"] } +ico = "0.4" + +[target.'cfg(windows)'.dependencies] +rfd = "0.15" +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_Graphics_Dwm", + "Win32_System_Console", + "Win32_System_Diagnostics_Debug", + "Win32_System_EventLog", + "Win32_UI_WindowsAndMessaging", +] } diff --git a/app/desktop/windows-setup/setup/build.rs b/app/desktop/windows-setup/setup/build.rs new file mode 100644 index 0000000..5dbf7d4 --- /dev/null +++ b/app/desktop/windows-setup/setup/build.rs @@ -0,0 +1,40 @@ +fn main() { + embed_windows_icon("../assets/app_window_icon.png"); + // welcome_brand_bg.png is exported from welcome_brand_bg.html via + // scripts/export-welcome-brand-bg.mjs (same Chromium CSS as the macOS DMG). + println!("cargo:rerun-if-changed=../assets/welcome_brand_bg.png"); +} + +fn embed_windows_icon(png_relative: &str) { + if std::env::var("CARGO_CFG_TARGET_OS").ok().as_deref() != Some("windows") { + return; + } + let manifest_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let png_path = manifest_dir.join(png_relative); + let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let ico_path = out_dir.join("app.ico"); + png_to_ico(&png_path, &ico_path); + let mut res = winres::WindowsResource::new(); + res.set_icon(ico_path.to_str().expect("ico path utf-8")); + if let Err(error) = res.compile() { + panic!("winres compile failed: {error}"); + } +} + +fn png_to_ico(png_path: &std::path::Path, ico_path: &std::path::Path) { + let image = image::open(png_path).expect("open icon png"); + let mut icon_dir = ico::IconDir::new(ico::ResourceType::Icon); + for size in [16u32, 32, 48, 256] { + let resized = image.resize_exact(size, size, image::imageops::FilterType::Lanczos3); + let rgba = resized.to_rgba8(); + let entry = ico::IconDirEntry::encode(&ico::IconImage::from_rgba_data( + size, + size, + rgba.into_raw(), + )) + .expect("encode icon entry"); + icon_dir.add_entry(entry); + } + let file = std::fs::File::create(ico_path).expect("create ico"); + icon_dir.write(file).expect("write ico"); +} diff --git a/app/desktop/windows-setup/setup/src/anim.rs b/app/desktop/windows-setup/setup/src/anim.rs new file mode 100644 index 0000000..739702f --- /dev/null +++ b/app/desktop/windows-setup/setup/src/anim.rs @@ -0,0 +1,68 @@ +pub fn ease_in_out_cubic(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + if t < 0.5 { + 4.0 * t * t * t + } else { + 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 + } +} + +pub fn ease_out_cubic(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + 1.0 - (1.0 - t).powi(3) +} + +#[derive(Clone, Copy)] +pub struct ScreenTransition { + pub from: S, + pub to: S, + pub t: f32, + pub duration: f32, +} + +impl ScreenTransition { + pub fn start(from: S, to: S) -> Self { + Self { + from, + to, + t: 0.0, + duration: 0.32, + } + } + + pub fn tick(&mut self, dt: f32) -> bool { + self.t = (self.t + dt / self.duration).min(1.0); + self.t >= 1.0 + } + + pub fn eased(&self) -> f32 { + ease_in_out_cubic(self.t) + } +} + +pub struct Lerped { + pub value: f32, + pub target: f32, +} + +impl Lerped { + pub fn new(v: f32) -> Self { + Self { value: v, target: v } + } + + pub fn set_target(&mut self, t: f32) { + self.target = t; + } + + pub fn tick(&mut self, dt: f32) { + self.value = animate_towards(self.value, self.target, dt, 8.0); + } +} + +/// Exponential ease toward `target` (MD3 standard ~250ms utility motion). +pub fn animate_towards(current: f32, target: f32, dt: f32, speed: f32) -> f32 { + if (current - target).abs() < 0.0005 { + return target; + } + current + (target - current) * (1.0 - (-speed * dt).exp()) +} diff --git a/app/desktop/windows-setup/setup/src/args.rs b/app/desktop/windows-setup/setup/src/args.rs new file mode 100644 index 0000000..55076a3 --- /dev/null +++ b/app/desktop/windows-setup/setup/src/args.rs @@ -0,0 +1,80 @@ +use fromchat_setup_common::{find_installed_by_registration_id, UNINSTALL_ARG, UPGRADE_ARG}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SetupMode { + Install, + Uninstall, + /// `--upgrade`: progress only, then exit (no confirm / done screens). + Upgrade, +} + +#[derive(Debug, Clone)] +pub struct SetupOptions { + pub mode: SetupMode, +} + +impl Default for SetupOptions { + fn default() -> Self { + Self { + mode: SetupMode::Install, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SetupLaunchError { + ConflictingFlags, + NotInstalled, +} + +impl SetupLaunchError { + pub fn title(self) -> &'static str { + match self { + Self::ConflictingFlags => crate::i18n::INSTALL_ERROR_TITLE, + Self::NotInstalled => crate::i18n::UPGRADE_ERROR_TITLE, + } + } + + pub fn body(self) -> &'static str { + match self { + Self::ConflictingFlags => { + "Нельзя одновременно использовать параметры --uninstall и --upgrade." + } + Self::NotInstalled => crate::i18n::NOT_INSTALLED, + } + } +} + +pub fn parse_setup_args(registration_id: &str) -> Result { + let args: Vec = std::env::args().collect(); + let uninstall = args.iter().any(|arg| arg == UNINSTALL_ARG); + let upgrade = args.iter().any(|arg| arg == UPGRADE_ARG); + + if uninstall && upgrade { + return Err(SetupLaunchError::ConflictingFlags); + } + + if upgrade + && find_installed_by_registration_id(registration_id).is_none() + { + return Err(SetupLaunchError::NotInstalled); + } + + Ok(SetupOptions { + mode: if uninstall { + SetupMode::Uninstall + } else if upgrade { + SetupMode::Upgrade + } else { + SetupMode::Install + }, + }) +} + +pub fn window_title_for_mode(mode: SetupMode) -> &'static str { + match mode { + SetupMode::Install => crate::i18n::WINDOW_TITLE, + SetupMode::Uninstall => crate::i18n::WINDOW_TITLE_UNINSTALL, + SetupMode::Upgrade => crate::i18n::WINDOW_TITLE_UPGRADE, + } +} diff --git a/app/desktop/windows-setup/setup/src/cookie4_sided_data.rs b/app/desktop/windows-setup/setup/src/cookie4_sided_data.rs new file mode 100644 index 0000000..e631583 --- /dev/null +++ b/app/desktop/windows-setup/setup/src/cookie4_sided_data.rs @@ -0,0 +1,150 @@ +//! Auto-generated from androidx MaterialShapes.cookie4() + RoundedPolygon.normalized(). +//! Do not edit by hand — regenerate via the setup Cookie4Sided dump script. + +/// Unit-square outline of `MaterialShapes.Cookie4Sided` (normalized). +pub const COOKIE4_SIDED_UNIT: &[[f32; 2]] = &[ + [0.918610, 0.621309], + [0.936054, 0.683631], + [0.935631, 0.743901], + [0.919596, 0.799860], + [0.890203, 0.849250], + [0.849708, 0.889811], + [0.800367, 0.919285], + [0.744434, 0.935412], + [0.684164, 0.935934], + [0.621814, 0.918592], + [0.617281, 0.916636], + [0.612747, 0.914680], + [0.608214, 0.912724], + [0.603681, 0.910768], + [0.599147, 0.908812], + [0.594614, 0.906856], + [0.590081, 0.904900], + [0.585547, 0.902944], + [0.581014, 0.900987], + [0.563545, 0.894380], + [0.545677, 0.889429], + [0.527526, 0.886133], + [0.509204, 0.884492], + [0.490826, 0.884507], + [0.472507, 0.886178], + [0.454361, 0.889503], + [0.436502, 0.894484], + [0.419043, 0.901120], + [0.414560, 0.903063], + [0.410076, 0.905007], + [0.405592, 0.906950], + [0.401109, 0.908893], + [0.396625, 0.910837], + [0.392142, 0.912780], + [0.387658, 0.914723], + [0.383174, 0.916667], + [0.378691, 0.918610], + [0.316369, 0.936054], + [0.256099, 0.935631], + [0.200140, 0.919596], + [0.150750, 0.890203], + [0.110189, 0.849708], + [0.080715, 0.800367], + [0.064588, 0.744434], + [0.064066, 0.684164], + [0.081408, 0.621814], + [0.083364, 0.617281], + [0.085320, 0.612747], + [0.087276, 0.608214], + [0.089232, 0.603681], + [0.091188, 0.599147], + [0.093144, 0.594614], + [0.095100, 0.590081], + [0.097056, 0.585547], + [0.099013, 0.581014], + [0.105620, 0.563545], + [0.110571, 0.545677], + [0.113867, 0.527526], + [0.115508, 0.509204], + [0.115493, 0.490826], + [0.113822, 0.472507], + [0.110497, 0.454361], + [0.105516, 0.436502], + [0.098880, 0.419043], + [0.096937, 0.414560], + [0.094993, 0.410076], + [0.093050, 0.405592], + [0.091107, 0.401109], + [0.089163, 0.396625], + [0.087220, 0.392142], + [0.085277, 0.387658], + [0.083333, 0.383174], + [0.081390, 0.378691], + [0.063946, 0.316369], + [0.064369, 0.256099], + [0.080404, 0.200140], + [0.109797, 0.150750], + [0.150292, 0.110189], + [0.199633, 0.080715], + [0.255566, 0.064588], + [0.315836, 0.064066], + [0.378186, 0.081408], + [0.382719, 0.083364], + [0.387253, 0.085320], + [0.391786, 0.087276], + [0.396319, 0.089232], + [0.400853, 0.091188], + [0.405386, 0.093144], + [0.409919, 0.095100], + [0.414453, 0.097056], + [0.418986, 0.099013], + [0.436455, 0.105620], + [0.454323, 0.110571], + [0.472474, 0.113867], + [0.490796, 0.115508], + [0.509174, 0.115493], + [0.527493, 0.113822], + [0.545639, 0.110497], + [0.563498, 0.105516], + [0.580957, 0.098880], + [0.585440, 0.096937], + [0.589924, 0.094993], + [0.594408, 0.093050], + [0.598891, 0.091107], + [0.603375, 0.089163], + [0.607858, 0.087220], + [0.612342, 0.085277], + [0.616826, 0.083333], + [0.621309, 0.081390], + [0.683631, 0.063946], + [0.743901, 0.064369], + [0.799860, 0.080404], + [0.849250, 0.109797], + [0.889811, 0.150292], + [0.919285, 0.199633], + [0.935412, 0.255566], + [0.935934, 0.315836], + [0.918592, 0.378186], + [0.916636, 0.382719], + [0.914680, 0.387253], + [0.912724, 0.391786], + [0.910768, 0.396319], + [0.908812, 0.400853], + [0.906856, 0.405386], + [0.904900, 0.409919], + [0.902944, 0.414453], + [0.900987, 0.418986], + [0.894380, 0.436455], + [0.889429, 0.454323], + [0.886133, 0.472474], + [0.884492, 0.490796], + [0.884507, 0.509174], + [0.886178, 0.527493], + [0.889503, 0.545639], + [0.894484, 0.563498], + [0.901120, 0.580957], + [0.903063, 0.585440], + [0.905007, 0.589924], + [0.906950, 0.594408], + [0.908893, 0.598891], + [0.910837, 0.603375], + [0.912780, 0.607858], + [0.914723, 0.612342], + [0.916667, 0.616826], +]; diff --git a/app/desktop/windows-setup/setup/src/cookie6_sided_data.rs b/app/desktop/windows-setup/setup/src/cookie6_sided_data.rs new file mode 100644 index 0000000..856bc9e --- /dev/null +++ b/app/desktop/windows-setup/setup/src/cookie6_sided_data.rs @@ -0,0 +1,168 @@ +//! Auto-generated from androidx MaterialShapes.cookie6() + RoundedPolygon.normalized(). + +/// Unit-square outline of `MaterialShapes.Cookie6Sided` (normalized). +pub const COOKIE6_SIDED_UNIT: &[[f32; 2]] = &[ + [0.770333, 0.850118], + [0.757878, 0.854047], + [0.745686, 0.858635], + [0.733783, 0.863870], + [0.722194, 0.869735], + [0.710944, 0.876217], + [0.700057, 0.883302], + [0.689559, 0.890974], + [0.679475, 0.899220], + [0.669829, 0.908026], + [0.669662, 0.908186], + [0.669496, 0.908347], + [0.669329, 0.908507], + [0.669163, 0.908668], + [0.668996, 0.908828], + [0.668830, 0.908989], + [0.668663, 0.909149], + [0.668497, 0.909310], + [0.668330, 0.909471], + [0.635151, 0.936289], + [0.598813, 0.956393], + [0.560221, 0.969783], + [0.520278, 0.976460], + [0.479890, 0.976425], + [0.439959, 0.969677], + [0.401391, 0.956219], + [0.365088, 0.936051], + [0.331956, 0.909174], + [0.322325, 0.900352], + [0.312256, 0.892088], + [0.301771, 0.884397], + [0.290897, 0.877293], + [0.279658, 0.870791], + [0.268080, 0.864905], + [0.256186, 0.859650], + [0.244002, 0.855040], + [0.231554, 0.851089], + [0.231331, 0.851025], + [0.231109, 0.850961], + [0.230887, 0.850897], + [0.230665, 0.850833], + [0.230442, 0.850769], + [0.230220, 0.850706], + [0.229998, 0.850642], + [0.229776, 0.850578], + [0.229553, 0.850514], + [0.189738, 0.835189], + [0.154158, 0.813771], + [0.123266, 0.787044], + [0.097512, 0.755792], + [0.077349, 0.720796], + [0.063227, 0.682842], + [0.055598, 0.642712], + [0.054912, 0.601189], + [0.061623, 0.559056], + [0.064448, 0.546305], + [0.066570, 0.533453], + [0.067988, 0.520527], + [0.068703, 0.507558], + [0.068715, 0.494574], + [0.068023, 0.481604], + [0.066627, 0.468676], + [0.064528, 0.455819], + [0.061725, 0.443063], + [0.061669, 0.442839], + [0.061613, 0.442614], + [0.061557, 0.442390], + [0.061502, 0.442165], + [0.061446, 0.441941], + [0.061390, 0.441717], + [0.061334, 0.441492], + [0.061279, 0.441268], + [0.061223, 0.441043], + [0.054587, 0.398899], + [0.055345, 0.357378], + [0.063045, 0.317261], + [0.077234, 0.279331], + [0.097459, 0.244372], + [0.123268, 0.213164], + [0.154207, 0.186492], + [0.189824, 0.165137], + [0.229667, 0.149882], + [0.242122, 0.145953], + [0.254314, 0.141365], + [0.266217, 0.136130], + [0.277806, 0.130265], + [0.289056, 0.123783], + [0.299943, 0.116698], + [0.310441, 0.109026], + [0.320525, 0.100780], + [0.330171, 0.091974], + [0.330338, 0.091814], + [0.330504, 0.091653], + [0.330671, 0.091493], + [0.330837, 0.091332], + [0.331004, 0.091172], + [0.331170, 0.091011], + [0.331337, 0.090851], + [0.331503, 0.090690], + [0.331670, 0.090529], + [0.364849, 0.063711], + [0.401187, 0.043607], + [0.439779, 0.030217], + [0.479722, 0.023540], + [0.520110, 0.023575], + [0.560041, 0.030323], + [0.598609, 0.043781], + [0.634912, 0.063949], + [0.668044, 0.090826], + [0.677675, 0.099648], + [0.687744, 0.107912], + [0.698229, 0.115603], + [0.709103, 0.122707], + [0.720342, 0.129209], + [0.731920, 0.135095], + [0.743814, 0.140350], + [0.755998, 0.144960], + [0.768446, 0.148911], + [0.768669, 0.148975], + [0.768891, 0.149039], + [0.769113, 0.149103], + [0.769335, 0.149167], + [0.769558, 0.149231], + [0.769780, 0.149294], + [0.770002, 0.149358], + [0.770224, 0.149422], + [0.770447, 0.149486], + [0.810262, 0.164811], + [0.845842, 0.186229], + [0.876734, 0.212956], + [0.902488, 0.244208], + [0.922651, 0.279204], + [0.936773, 0.317158], + [0.944402, 0.357288], + [0.945088, 0.398811], + [0.938377, 0.440944], + [0.935552, 0.453695], + [0.933430, 0.466547], + [0.932012, 0.479473], + [0.931297, 0.492442], + [0.931285, 0.505426], + [0.931977, 0.518396], + [0.933373, 0.531324], + [0.935472, 0.544181], + [0.938275, 0.556937], + [0.938331, 0.557161], + [0.938387, 0.557386], + [0.938443, 0.557610], + [0.938498, 0.557835], + [0.938554, 0.558059], + [0.938610, 0.558283], + [0.938666, 0.558508], + [0.938721, 0.558732], + [0.938777, 0.558957], + [0.945413, 0.601101], + [0.944655, 0.642622], + [0.936955, 0.682739], + [0.922766, 0.720669], + [0.902541, 0.755628], + [0.876732, 0.786836], + [0.845793, 0.813508], + [0.810176, 0.834863], + [0.770333, 0.850118], +]; diff --git a/app/desktop/windows-setup/setup/src/fonts.rs b/app/desktop/windows-setup/setup/src/fonts.rs new file mode 100644 index 0000000..dd17a36 --- /dev/null +++ b/app/desktop/windows-setup/setup/src/fonts.rs @@ -0,0 +1,138 @@ +use eframe::egui::{self, FontData, FontDefinitions, FontFamily, FontId}; + +fn load_font(bytes: &'static [u8]) -> FontData { + FontData::from_static(bytes) +} + +fn load_icon_font(bytes: &'static [u8]) -> FontData { + let mut font = FontData::from_static(bytes); + font.tweak.y_offset_factor = 0.0; + font +} + +pub fn install(ctx: &egui::Context) { + let mut fonts = FontDefinitions::default(); + + fonts + .font_data + .insert("google_sans".to_owned(), load_font(include_bytes!( + "../../assets/fonts/google_sans_regular.ttf" + ))); + fonts.font_data.insert( + "google_sans_medium".to_owned(), + load_font(include_bytes!("../../assets/fonts/google_sans_medium.ttf")), + ); + fonts.font_data.insert( + "google_sans_bold".to_owned(), + load_font(include_bytes!("../../assets/fonts/google_sans_bold.ttf")), + ); + fonts.font_data.insert( + "montserrat_bold".to_owned(), + load_font(include_bytes!("../../assets/fonts/montserrat_bold.ttf")), + ); + fonts + .font_data + .insert("montserrat".to_owned(), load_font(include_bytes!( + "../../assets/fonts/montserrat_latin.ttf" + ))); + fonts.font_data.insert( + "montserrat_cyr".to_owned(), + load_font(include_bytes!("../../assets/fonts/montserrat_cyrillic.ttf")), + ); + fonts.font_data.insert( + "material_symbols".to_owned(), + load_icon_font(include_bytes!("../../assets/MaterialSymbolsOutlined.ttf")), + ); + + // Google Sans for UI; Montserrat only for the brand wordmark. Cyrillic falls back per glyph. + fonts.families.insert( + FontFamily::Name("body".into()), + vec![ + "google_sans".to_owned(), + "montserrat_cyr".to_owned(), + "montserrat".to_owned(), + ], + ); + fonts.families.insert( + FontFamily::Name("body_medium".into()), + vec![ + "google_sans_medium".to_owned(), + "montserrat_cyr".to_owned(), + "montserrat".to_owned(), + ], + ); + fonts.families.insert( + FontFamily::Name("body_bold".into()), + vec![ + "google_sans_bold".to_owned(), + "montserrat_cyr".to_owned(), + "montserrat".to_owned(), + ], + ); + fonts.families.insert( + FontFamily::Name("wordmark".into()), + vec![ + "montserrat_bold".to_owned(), + "montserrat_cyr".to_owned(), + "montserrat".to_owned(), + ], + ); + fonts + .families + .insert(FontFamily::Name("material".into()), vec!["material_symbols".to_owned()]); + + fonts + .families + .get_mut(&FontFamily::Proportional) + .expect("proportional") + .insert(0, "google_sans".to_owned()); + fonts + .families + .get_mut(&FontFamily::Proportional) + .expect("proportional") + .push("montserrat_cyr".to_owned()); + + ctx.set_fonts(fonts); + ctx.options_mut(|options| options.preload_font_glyphs = true); +} + +pub fn body(size: f32) -> FontId { + FontId::new(size, FontFamily::Name("body".into())) +} + +pub fn body_medium(size: f32) -> FontId { + FontId::new(size, FontFamily::Name("body_medium".into())) +} + +pub fn body_bold(size: f32) -> FontId { + FontId::new(size, FontFamily::Name("body_bold".into())) +} + +pub fn wordmark(size: f32) -> FontId { + FontId::new(size, FontFamily::Name("wordmark".into())) +} + +/// Brand wordmark size matches `BrandTitle.kt` (29sp). +pub fn wordmark_brand() -> FontId { + wordmark(29.0) +} + +/// M3 `titleLarge` (22sp, medium). +pub fn title_large() -> FontId { + body_medium(22.0) +} + +/// M3 `labelSmall` (11sp, medium). +pub fn label_small() -> FontId { + body_medium(11.0) +} + +/// M3 `bodyLarge` (16sp) for text fields. +pub fn body_large() -> FontId { + body(16.0) +} + +/// M3 `labelLarge` (14sp / 500 / 20sp line height) — filled button label. +pub fn label_large() -> FontId { + body_medium(14.0) +} diff --git a/app/desktop/windows-setup/setup/src/i18n.rs b/app/desktop/windows-setup/setup/src/i18n.rs new file mode 100644 index 0000000..2650298 --- /dev/null +++ b/app/desktop/windows-setup/setup/src/i18n.rs @@ -0,0 +1,99 @@ +//! Russian installer copy (primary locale for Windows setup). + +pub const WINDOW_TITLE: &str = "Установка FromChat"; +pub const WINDOW_TITLE_UNINSTALL: &str = "Удаление FromChat"; +pub const WINDOW_TITLE_UPGRADE: &str = "Обновление FromChat"; +pub const UPGRADE_ERROR_TITLE: &str = "Ошибка обновления"; +pub const TAGLINE: &str = "100% бесплатный и открытый мессенджер."; +pub const INSTALL: &str = "Установка"; +pub const INSTALL_DESC: &str = + "Полная установка с ярлыками в \"Пуске\" и на рабочем столе (рекомендуется)."; +pub const DELETE: &str = "Удалить"; +pub const DELETE_DESC: &str = + "Удалить установленную копию FromChat с этого компьютера."; +pub const PRESERVE_USER_DATA: &str = "Сохранить пользовательские данные"; +pub const UPGRADE: &str = "Обновить"; +pub const UPGRADE_DESC: &str = + "Обновить существующую копию FromChat на этом компьютере без потери данных."; +pub const UPGRADE_SELECT_TITLE: &str = "Обновление"; +pub const UPGRADE_CONFIRM_TITLE: &str = "Подтверждение обновления"; +pub const UPGRADE_CONFIRM_BODY: &str = + "FromChat будет обновлён до последней версии. Пользовательские данные и настройки сохранятся."; +pub const UPGRADE_ACTION: &str = "Обновить"; +pub const UPGRADING: &str = "Обновление…"; +pub const UPGRADE_REPLACING_FILES: &str = "Замена файлов…"; +pub const NEXT: &str = "Далее"; +pub const UNINSTALL_TITLE: &str = "Удаление"; +pub const UNINSTALL_CONFIRM_BODY: &str = + "FromChat будет удалён с этого компьютера вместе со всеми файлами программы. Это действие нельзя отменить."; +pub const UNINSTALL_ACTION: &str = "Удалить"; +pub const UNINSTALLING: &str = "Удаление…"; +pub const REMOVING_FILES: &str = "Удаление файлов…"; +pub const UNINSTALL_DONE_TITLE: &str = "FromChat удален"; +pub const UNINSTALL_DONE_SUBTITLE: &str = "Нам очень жаль, что вы уходите."; +pub const UNINSTALL_SUPPORT_PREFIX: &str = "Если возникли проблемы, напишите в "; +pub const UNINSTALL_SUPPORT_LABEL: &str = "поддержку"; +pub const NOT_INSTALLED: &str = "FromChat не установлен на этом компьютере."; +pub const PORTABLE: &str = "Портативная версия"; +pub const PORTABLE_DESC: &str = + "Для опытных пользователей. Распаковка FromChat без установки, идеально для флешек или внешних SSD."; +pub const INSTALL_LOCATION: &str = "Папка установки"; +pub const DESTINATION: &str = "Папка назначения"; +pub const BROWSE: &str = "Обзор"; +pub const ALL_USERS: &str = "Установить для всех пользователей (администратор)"; +pub const START_MENU: &str = "Ярлык в меню «Пуск»"; +pub const DESKTOP: &str = "Ярлык на рабочем столе"; +pub const INSTALL_ACTION: &str = "Установить"; +pub const EXTRACT_ACTION: &str = "Извлечь"; +pub const LAUNCH_ACTION: &str = "Запустить"; +pub const CLOSE: &str = "Закрыть"; +pub const CLOSE_CONFIRM_TITLE: &str = "Закрыть мастер установки?"; +pub const CLOSE_CONFIRM_BODY: &str = + "Установка или удаление ещё не завершены. Вы уверены, что хотите выйти?"; +pub const CLOSE_CONFIRM_PROMPT: &str = + "Установка или удаление ещё не завершены.\n\nНажмите «Да», чтобы закрыть мастер установки, или «Нет», чтобы остаться."; +pub const CLOSE_CONFIRM_STAY: &str = "Остаться"; +pub const CLOSE_CONFIRM_EXIT: &str = "Закрыть"; +pub const DIALOG_OK: &str = "OK"; +pub const INSTALL_ERROR_TITLE: &str = "Ошибка установки"; +pub const INSTALL_ERROR_BODY: &str = + "Произошла ошибка при установке. Продолжить установку невозможно."; +pub const UNINSTALL_ERROR_TITLE: &str = "Ошибка удаления"; +pub const UNINSTALL_ERROR_BODY: &str = + "Произошла ошибка при удалении. Продолжить удаление невозможно."; +pub const INSTALLING: &str = "Установка…"; + +pub const DONE_TITLE: &str = "Установка завершена"; +pub const DONE_OPEN_HINT_DESKTOP: &str = + "Чтобы открыть FromChat, нажмите на ярлык на рабочем столе или найдите его в меню «Пуск»."; +pub const DONE_OPEN_HINT: &str = "Чтобы открыть FromChat, найдите его в меню «Пуск»."; +pub const DONE_OPEN_HINT_PORTABLE: &str = + "Чтобы открыть FromChat, запустите «FromChat Portable.exe» в выбранной папке."; +pub const DONE_TELEGRAM_PREFIX: &str = "Подпишитесь на "; +pub const DONE_TELEGRAM_CHANNEL_LABEL: &str = "@fromchat_ch"; +pub const DONE_TELEGRAM_MIDDLE: &str = + " в Telegram, чтобы получать важные новости о мессенджере. Остались вопросы? Пишите в "; +pub const DONE_TELEGRAM_SUPPORT_LABEL: &str = "поддержку (сообщения канала)"; +pub const DONE_TELEGRAM_SUPPORT_URL: &str = "https://t.me/fromchat_ch?direct"; +pub const DONE_TELEGRAM_CHANNEL_URL: &str = "https://t.me/fromchat_ch"; + +pub const PREPARING: &str = "Подготовка…"; +pub const READING_PACKAGE: &str = "Чтение пакета…"; +pub const EXTRACTING: &str = "Распаковка…"; +pub const HIDING_FILES: &str = "Скрытие служебных файлов…"; +pub const REQUESTING_ADMIN: &str = "Запрос прав администратора…"; +pub const ELEVATION_DENIED: &str = + "Не удалось получить права администратора. Операция отменена."; +pub const COPYING: &str = "Копирование…"; +pub const REGISTERING: &str = "Регистрация…"; + +pub fn version_label(version: &str) -> String { + format!("Версия {version}") +} + +pub fn installed_copy_title(edition: &str, version: Option<&str>) -> String { + match version { + Some(version) => format!("{edition} {version}"), + None => edition.to_owned(), + } +} diff --git a/app/desktop/windows-setup/setup/src/icons.rs b/app/desktop/windows-setup/setup/src/icons.rs new file mode 100644 index 0000000..0fc90cf --- /dev/null +++ b/app/desktop/windows-setup/setup/src/icons.rs @@ -0,0 +1,27 @@ +use eframe::egui::{self, FontFamily, FontId}; + +pub const CLOSE: &str = "\u{e5cd}"; +pub const ROCKET_LAUNCH: &str = "\u{eb9b}"; +pub const DONE_ALL: &str = "\u{e877}"; +pub const OPEN_IN_NEW: &str = "\u{e89e}"; +pub const RSS_FEED: &str = "\u{e0e5}"; +pub const ARROW_BACK: &str = "\u{e5c4}"; +pub const ARROW_FORWARD: &str = "\u{e5c8}"; +pub const DOWNLOAD: &str = "\u{f090}"; +pub const FOLDER: &str = "\u{e2c7}"; +pub const FOLDER_OPEN: &str = "\u{e2c8}"; +pub const CHECK: &str = "\u{e5ca}"; +pub const UNARCHIVE: &str = "\u{e169}"; +pub const DELETE: &str = "\u{e872}"; +pub const SUPPORT: &str = "\u{ef73}"; +/// Material Symbols `system_update_alt` (closest match to `system_upgrade_alt` in the font). +pub const SYSTEM_UPGRADE_ALT: &str = "\u{e8d7}"; +pub const MINIMIZE: &str = "\u{e15b}"; + +pub fn font(size: f32) -> FontId { + FontId::new(size, FontFamily::Name("material".into())) +} + +pub fn label(ui: &mut egui::Ui, icon: &str, size: f32, color: egui::Color32) { + ui.label(egui::RichText::new(icon).font(font(size)).color(color)); +} diff --git a/app/desktop/windows-setup/setup/src/main.rs b/app/desktop/windows-setup/setup/src/main.rs new file mode 100644 index 0000000..90c0c9f --- /dev/null +++ b/app/desktop/windows-setup/setup/src/main.rs @@ -0,0 +1,157 @@ +//! FromChat Windows setup UI (egui) — animated, no WebView. + +#![windows_subsystem = "windows"] + +mod anim; +mod args; +mod cookie4_sided_data; +mod cookie6_sided_data; +mod fonts; +mod icons; +mod i18n; +mod theme; +mod ui; +mod widgets; +mod win_chrome; +mod win_console; +mod win_dialog; +mod win_log; + +use anyhow::Result; +use eframe::egui; +use fromchat_setup_common::{read_bundle_from_exe, BRANDING_PNG, SETUP_MAGIC}; + +fn main() { + #[cfg(windows)] + win_console::configure(); + + win_log::init(); + + if let Err(error) = run() { + win_log::error(&format!("setup exited with error: {error:#}")); + std::process::exit(1); + } + win_log::info("setup exited normally"); +} + +fn run() -> Result<()> { + win_log::info("building NativeOptions"); + + let registration_id = read_bundle_from_exe( + &std::env::current_exe().unwrap_or_default(), + SETUP_MAGIC, + ) + .map(|bundle| bundle.meta.registration_id) + .unwrap_or_else(|error| { + win_log::warn(&format!("bundle registration id read failed: {error:#}")); + "FromChat".to_owned() + }); + + let options = match args::parse_setup_args(®istration_id) { + Ok(options) => options, + Err(error) => { + #[cfg(windows)] + { + crate::win_dialog::show_message(error.title(), error.body()); + } + return Err(anyhow::anyhow!("{}", error.body())); + } + }; + + let window_title = args::window_title_for_mode(options.mode).to_owned(); + + let icon = match load_window_icon() { + Ok(icon) => { + win_log::info("window icon loaded"); + Some(icon) + } + Err(error) => { + win_log::warn(&format!("window icon failed: {error:#}; continuing without icon")); + None + } + }; + + let mut viewport = egui::ViewportBuilder::default() + .with_inner_size([theme::WINDOW_WIDTH, theme::WINDOW_HEIGHT]) + .with_resizable(false) + .with_decorations(false) + .with_transparent(false) + .with_title(&window_title); + if let Some(icon) = icon { + viewport = viewport.with_icon(icon); + } + + let base_options = eframe::NativeOptions { + viewport, + multisampling: 0, + ..Default::default() + }; + + #[cfg(windows)] + let renderers = [eframe::Renderer::Glow]; + #[cfg(not(windows))] + let renderers = [eframe::Renderer::Glow]; + + let mut last_error = None; + for renderer in renderers { + let mut options_native = base_options.clone(); + options_native.renderer = renderer; + + #[cfg(windows)] + if renderer == eframe::Renderer::Wgpu && std::env::var_os("WGPU_BACKEND").is_none() { + std::env::set_var("WGPU_BACKEND", "dx12"); + win_log::info("wgpu fallback: WGPU_BACKEND=dx12"); + } + + win_log::info(&format!("calling eframe::run_native (renderer={renderer:?})")); + + let setup_options = options.clone(); + let result = eframe::run_native( + &window_title, + options_native, + Box::new(move |cc| { + win_log::info("eframe creation callback"); + let app = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ui::SetupApp::new(cc, setup_options) + })) + .map_err(|_| { + win_log::error("panic while constructing SetupApp"); + Box::from("SetupApp::new panicked") as Box + })?; + win_log::info("SetupApp constructed"); + Ok(Box::new(app)) + }), + ); + + match result { + Ok(()) => { + win_log::info(&format!("eframe finished OK (renderer={renderer:?})")); + return Ok(()); + } + Err(error) => { + win_log::error(&format!("eframe failed (renderer={renderer:?}): {error}")); + last_error = Some(error); + } + } + } + + Err(match last_error { + Some(error) => { + win_log::error("all renderers failed"); + anyhow::anyhow!("eframe: {error}") + } + None => anyhow::anyhow!("eframe: no renderer attempted"), + }) +} + +fn load_window_icon() -> anyhow::Result { + let image = image::load_from_memory(BRANDING_PNG)?; + let rgba = image.to_rgba8(); + let width = rgba.width(); + let height = rgba.height(); + Ok(egui::IconData { + rgba: rgba.into_raw(), + width, + height, + }) +} diff --git a/app/desktop/windows-setup/setup/src/theme.rs b/app/desktop/windows-setup/setup/src/theme.rs new file mode 100644 index 0000000..b6ba7a5 --- /dev/null +++ b/app/desktop/windows-setup/setup/src/theme.rs @@ -0,0 +1,362 @@ +use crate::cookie4_sided_data; +use crate::cookie6_sided_data; +use eframe::egui::{ + epaint::Mesh, + Color32, FontId, Painter, Pos2, Rect, Rounding, Shape, +}; + +// Material 3 darkColorScheme() — matches Android / JVM desktop when Material You is off. +pub const SURFACE: Color32 = Color32::from_rgb(0x14, 0x12, 0x18); +pub const ON_SURFACE: Color32 = Color32::from_rgb(0xE6, 0xE0, 0xE9); +pub const ON_SURFACE_VARIANT: Color32 = Color32::from_rgb(0xCA, 0xC4, 0xD0); +pub const SURFACE_CONTAINER: Color32 = Color32::from_rgb(0x21, 0x1F, 0x26); +pub const SURFACE_CONTAINER_HIGH: Color32 = Color32::from_rgb(0x2B, 0x29, 0x30); +pub const SURFACE_CONTAINER_HIGHEST: Color32 = Color32::from_rgb(0x36, 0x34, 0x3B); +pub const PRIMARY: Color32 = Color32::from_rgb(0xD0, 0xBC, 0xFF); +pub const ON_PRIMARY: Color32 = Color32::from_rgb(0x38, 0x1E, 0x72); +pub const PRIMARY_CONTAINER: Color32 = Color32::from_rgb(0x4F, 0x37, 0x8B); +pub const ON_PRIMARY_CONTAINER: Color32 = Color32::from_rgb(0xE8, 0xDD, 0xFF); +pub const OUTLINE: Color32 = Color32::from_rgb(0x93, 0x8F, 0x99); +pub const OUTLINE_VARIANT: Color32 = Color32::from_rgb(0x49, 0x45, 0x4F); +pub const ERROR: Color32 = Color32::from_rgb(0xF2, 0xB8, 0xB5); +pub const ERROR_FILLED: Color32 = Color32::from_rgb(0xB3, 0x26, 0x1E); +pub const ON_ERROR_FILLED: Color32 = Color32::from_rgb(0xFF, 0xFF, 0xFF); + +/// Custom title bar label / chrome when the window is inactive. +pub const TITLE_BAR_INACTIVE: Color32 = OUTLINE; + +pub const WINDOW_WIDTH: f32 = 480.0; +pub const WINDOW_HEIGHT: f32 = 588.0; +pub const TITLE_BAR_HEIGHT: f32 = 40.0; +/// Window corner radius (painted fill; Win11 also rounds via DWM). +pub const WINDOW_CORNER_RADIUS: f32 = 12.0; + +/// M3 `ButtonDefaults` — small filled button (material.io/components/buttons/specs). +pub const FILLED_BUTTON_HEIGHT: f32 = 40.0; +pub const FILLED_BUTTON_MIN_WIDTH: f32 = 58.0; +pub const FILLED_BUTTON_ICON_SIZE: f32 = 18.0; +pub const FILLED_BUTTON_ICON_GAP: f32 = 8.0; +pub const FILLED_BUTTON_PAD_HORIZONTAL: f32 = 24.0; +pub const FILLED_BUTTON_PAD_START_WITH_ICON: f32 = 16.0; +pub const FILLED_BUTTON_PAD_END_WITH_ICON: f32 = 24.0; +pub const FILLED_BUTTON_PAD_END_WITH_TRAILING_ICON: f32 = 16.0; + +/// M3 `IconButtonDefaults` — standard icon button (48dp touch target, 24dp icon). +pub const ICON_BUTTON_SIZE: f32 = 48.0; +pub const ICON_BUTTON_ICON_SIZE: f32 = 24.0; + +/// Compact nav back control (40dp — matches small filled button height). +pub const NAV_BACK_BUTTON_SIZE: f32 = FILLED_BUTTON_HEIGHT; +pub const NAV_BACK_STATE_LAYER_SIZE: f32 = 40.0; + +/// M3 text button with icon (`ButtonDefaults`: 40dp height, 18dp icon, 8dp gap). +pub const TEXT_BUTTON_HEIGHT: f32 = 40.0; +pub const TEXT_BUTTON_ICON_SIZE: f32 = 18.0; +pub const TEXT_BUTTON_ICON_GAP: f32 = 8.0; +pub const TEXT_BUTTON_PAD_START_WITH_ICON: f32 = 12.0; +pub const TEXT_BUTTON_PAD_END_WITH_ICON: f32 = 16.0; +pub const TEXT_BUTTON_MIN_WIDTH: f32 = 48.0; + +/// M3 checkbox state layer (40dp circle centered on 18dp box). +pub const CHECKBOX_STATE_LAYER_SIZE: f32 = 40.0; +pub const CHECKBOX_SIZE: f32 = 18.0; + +/// Choice card horizontal insets. +pub const CHOICE_CARD_PAD: f32 = 16.0; +pub const CHOICE_CARD_ICON_GAP: f32 = 12.0; +pub const CHECKBOX_STROKE: f32 = 2.0; +pub const CHECKBOX_CORNER_RADIUS: f32 = 2.0; +pub const CHECKBOX_LABEL_GAP: f32 = 16.0; +pub const CHECKBOX_MIN_TOUCH_HEIGHT: f32 = 48.0; + +/// M3 filled / expressive text field (`OutlinedTextField` shape in app flows). +pub const TEXT_FIELD_HEIGHT: f32 = 56.0; +pub const TEXT_FIELD_CORNER_RADIUS: f32 = 18.0; +pub const TEXT_FIELD_PAD_HORIZONTAL: f32 = 16.0; +pub const TEXT_FIELD_TRAILING_ICON_WIDTH: f32 = 48.0; + +/// M3 `LinearProgressIndicator` — 4dp track. +pub const LINEAR_PROGRESS_HEIGHT: f32 = 4.0; +pub const LINEAR_PROGRESS_CORNER_RADIUS: f32 = 2.0; + +/// M3 list leading icon container (tonal). +pub const LIST_ICON_CONTAINER_SIZE: f32 = 40.0; +pub const LIST_ICON_CONTAINER_RADIUS: f32 = 12.0; +pub const LIST_ICON_SIZE: f32 = 24.0; + +/// M3 `labelSmall` → field label gap above control. +pub const FIELD_LABEL_GAP: f32 = 8.0; + +/// Expressive hero (`ExpressiveIconFrame.kt`). +pub const EXPRESSIVE_HERO_SIZE: f32 = 112.0; +pub const EXPRESSIVE_HERO_ICON_SIZE: f32 = 52.0; +pub const EXPRESSIVE_HERO_TITLE_GAP: f32 = 16.0; + +/// `BrandTitle.kt` horizontal gradient (web / Android). +const TITLE_GRADIENT: [(f32, Color32); 6] = [ + (0.0, Color32::from_rgb(0x63, 0x66, 0xF1)), + (0.2, Color32::from_rgb(0x3B, 0x82, 0xF6)), + (0.4, Color32::from_rgb(0x93, 0x33, 0xEA)), + (0.6, Color32::from_rgb(0xA8, 0x55, 0xF7)), + (0.8, Color32::from_rgb(0xD9, 0x46, 0xEF)), + (1.0, Color32::from_rgb(0xEC, 0x48, 0x99)), +]; + +pub fn draw_background(painter: &Painter, rect: Rect) { + painter.rect_filled(rect, Rounding::same(WINDOW_CORNER_RADIUS), SURFACE); +} + +pub fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 { + let t = t.clamp(0.0, 1.0); + Color32::from_rgba_unmultiplied( + (a.r() as f32 + (b.r() as f32 - a.r() as f32) * t) as u8, + (a.g() as f32 + (b.g() as f32 - a.g() as f32) * t) as u8, + (a.b() as f32 + (b.b() as f32 - a.b() as f32) * t) as u8, + (a.a() as f32 + (b.a() as f32 - a.a() as f32) * t) as u8, + ) +} + +pub fn title_gradient_at(t: f32) -> Color32 { + let t = t.clamp(0.0, 1.0); + for i in 0..TITLE_GRADIENT.len() - 1 { + let (t0, c0) = TITLE_GRADIENT[i]; + let (t1, c1) = TITLE_GRADIENT[i + 1]; + if t <= t1 { + let f = if (t1 - t0).abs() < f32::EPSILON { + 0.0 + } else { + (t - t0) / (t1 - t0) + }; + return lerp_color(c0, c1, f); + } + } + TITLE_GRADIENT[TITLE_GRADIENT.len() - 1].1 +} + +pub fn draw_brand_wordmark(painter: &Painter, center: Pos2, text: &str, font: FontId) { + // Measure with PLACEHOLDER so painter.galley can apply per-glyph brand colors. + let measure = painter.layout( + text.to_owned(), + font.clone(), + Color32::PLACEHOLDER, + f32::INFINITY, + ); + let size = measure.size(); + let left = center.x - size.x * 0.5; + let top = center.y - size.y * 0.5; + let mut x = left; + for ch in text.chars() { + let piece = ch.to_string(); + let g = painter.layout(piece, font.clone(), Color32::PLACEHOLDER, f32::INFINITY); + let gw = g.size().x; + let mid = (x - left + gw * 0.5) / size.x.max(1.0); + painter.galley(Pos2::new(x, top), g, title_gradient_at(mid)); + x += gw; + } +} + +/// Official `MaterialShapes.Cookie4Sided` outline (from androidx MaterialShapes.cookie4). +pub fn draw_cookie4_sided(painter: &Painter, rect: Rect, fill: Color32) { + draw_unit_polygon(painter, rect, fill, cookie4_sided_data::COOKIE4_SIDED_UNIT); +} + +/// Official `MaterialShapes.Cookie6Sided` outline (from androidx MaterialShapes.cookie6). +pub fn draw_cookie6_sided(painter: &Painter, rect: Rect, fill: Color32) { + draw_unit_polygon(painter, rect, fill, cookie6_sided_data::COOKIE6_SIDED_UNIT); +} + +/// Expressive hero: MaterialShapes.Cookie6Sided (portable flow). +pub fn draw_expressive_cookie6_hero(painter: &Painter, rect: Rect) { + draw_cookie6_sided(painter, rect, PRIMARY_CONTAINER); +} + +/// Expressive hero: symmetric circle (done flow). +pub fn draw_expressive_circle_hero(painter: &Painter, rect: Rect) { + painter.circle_filled(rect.center(), rect.width() * 0.5, PRIMARY_CONTAINER); +} + +fn draw_unit_polygon(painter: &Painter, rect: Rect, fill: Color32, unit: &[[f32; 2]]) { + let mut points: Vec = unit + .iter() + .map(|[u, v]| { + Pos2::new( + rect.left() + u * rect.width(), + rect.top() + v * rect.height(), + ) + }) + .collect(); + simplify_collinear(&mut points, 0.35); + triangulate_fill(painter, &points, fill); +} + +fn simplify_collinear(points: &mut Vec, eps: f32) { + if points.len() <= 3 { + return; + } + let mut simplified = Vec::with_capacity(points.len()); + simplified.push(points[0]); + for i in 1..points.len() - 1 { + let a = simplified[simplified.len() - 1]; + let b = points[i]; + let c = points[i + 1]; + let ab = b - a; + let bc = c - b; + let cross = ab.x * bc.y - ab.y * bc.x; + if cross.abs() > eps { + simplified.push(b); + } + } + simplified.push(*points.last().expect("non-empty")); + *points = simplified; +} + +fn triangulate_fill(painter: &Painter, points: &[Pos2], fill: Color32) { + if points.len() < 3 { + return; + } + let flat: Vec = points + .iter() + .flat_map(|p| [f64::from(p.x), f64::from(p.y)]) + .collect(); + let Ok(indices) = earcutr::earcut(&flat, &[], 2) else { + return; + }; + let mut mesh = Mesh::default(); + let base = mesh.vertices.len() as u32; + for p in points { + mesh.colored_vertex(*p, fill); + } + for tri in indices.chunks_exact(3) { + mesh.add_triangle( + base + tri[0] as u32, + base + tri[1] as u32, + base + tri[2] as u32, + ); + } + painter.add(Shape::mesh(mesh)); +} + +/// Expressive hero: single Cookie4Sided fill (`ExpressiveIconFrame` / step hero). +pub fn draw_expressive_cookie_hero(painter: &Painter, rect: Rect) { + draw_cookie4_sided(painter, rect, PRIMARY_CONTAINER); +} + +/// M3 filled error button (error role). +pub fn draw_filled_error_button(painter: &Painter, rect: Rect, hover_t: f32, press_t: f32) { + let rounding = Rounding::same(rect.height() * 0.5); + painter.rect_filled(rect, rounding, ERROR_FILLED); + let alpha = state_layer_alpha(hover_t, press_t); + if alpha > 0.001 { + painter.rect_filled( + rect, + rounding, + Color32::from_rgba_unmultiplied( + ON_ERROR_FILLED.r(), + ON_ERROR_FILLED.g(), + ON_ERROR_FILLED.b(), + (alpha * 255.0) as u8, + ), + ); + } +} + +/// M3 filled button: solid primary + on-primary state layer (8% hover / 12% pressed). +pub fn draw_filled_button(painter: &Painter, rect: Rect, hover_t: f32, press_t: f32) { + let rounding = Rounding::same(rect.height() * 0.5); + painter.rect_filled(rect, rounding, PRIMARY); + let alpha = state_layer_alpha(hover_t, press_t); + if alpha > 0.001 { + painter.rect_filled( + rect, + rounding, + Color32::from_rgba_unmultiplied( + ON_PRIMARY.r(), + ON_PRIMARY.g(), + ON_PRIMARY.b(), + (alpha * 255.0) as u8, + ), + ); + } +} + +/// M3 surface-tinted state layer (checkbox / nav icon hover). +pub fn draw_surface_state_layer(painter: &Painter, center: Pos2, radius: f32, hover_t: f32, press_t: f32) { + let alpha = state_layer_alpha(hover_t, press_t); + if alpha > 0.001 { + painter.circle_filled( + center, + radius, + Color32::from_rgba_unmultiplied( + ON_SURFACE.r(), + ON_SURFACE.g(), + ON_SURFACE.b(), + (alpha * 255.0) as u8, + ), + ); + } +} + +/// Square state layer for custom title-bar controls. +pub fn draw_surface_state_layer_rect(painter: &Painter, rect: Rect, hover_t: f32, press_t: f32) { + let alpha = state_layer_alpha(hover_t, press_t); + if alpha > 0.001 { + painter.rect_filled( + rect, + Rounding::ZERO, + Color32::from_rgba_unmultiplied( + ON_SURFACE.r(), + ON_SURFACE.g(), + ON_SURFACE.b(), + (alpha * 255.0) as u8, + ), + ); + } +} + +/// Windows-style close button hover (error container fill). +pub fn draw_title_bar_close_state(painter: &Painter, rect: Rect, hover_t: f32, press_t: f32) { + let t = hover_t.max(press_t); + if t <= 0.001 { + return; + } + let color = if press_t > hover_t { + Color32::from_rgb(0x8C, 0x1D, 0x18) + } else { + ERROR_FILLED + }; + painter.rect_filled( + rect, + Rounding::ZERO, + Color32::from_rgba_unmultiplied( + color.r(), + color.g(), + color.b(), + (t * 255.0) as u8, + ), + ); +} + +/// M3 text button: primary state layer on surface (8% hover / 12% pressed). +pub fn draw_text_button_state(painter: &Painter, rect: Rect, hover_t: f32, press_t: f32) { + let alpha = state_layer_alpha(hover_t, press_t); + if alpha > 0.001 { + painter.rect_filled( + rect, + Rounding::same(rect.height() * 0.5), + Color32::from_rgba_unmultiplied( + PRIMARY.r(), + PRIMARY.g(), + PRIMARY.b(), + (alpha * 255.0) as u8, + ), + ); + } +} + +fn state_layer_alpha(hover_t: f32, press_t: f32) -> f32 { + press_t * 0.12 + hover_t * 0.08 * (1.0 - press_t) +} + +pub fn draw_tonal_icon_container(painter: &Painter, rect: Rect) { + painter.rect_filled(rect, Rounding::same(LIST_ICON_CONTAINER_RADIUS), PRIMARY_CONTAINER); +} diff --git a/app/desktop/windows-setup/setup/src/ui.rs b/app/desktop/windows-setup/setup/src/ui.rs new file mode 100644 index 0000000..b2d0e31 --- /dev/null +++ b/app/desktop/windows-setup/setup/src/ui.rs @@ -0,0 +1,1928 @@ +use crate::anim::{Lerped, ScreenTransition}; +use crate::args::{SetupMode, SetupOptions}; +use crate::fonts::{self, body, body_medium, wordmark_brand, title_large}; +use crate::i18n; +use crate::icons::{self, CLOSE, DELETE, DOWNLOAD, FOLDER, DONE_ALL, MINIMIZE, OPEN_IN_NEW, RSS_FEED, SUPPORT, SYSTEM_UPGRADE_ALT}; +use crate::theme::{self, ON_SURFACE, ON_SURFACE_VARIANT, ON_ERROR_FILLED, PRIMARY, SURFACE, TITLE_BAR_HEIGHT, TITLE_BAR_INACTIVE}; +use crate::widgets::{self, H_PADDING}; +use eframe::egui::{self, Color32, Pos2, Rect, Rounding, Sense, TextureHandle, Ui, Vec2}; +use fromchat_setup_common::{ + copy_uninstall_setup, extract_zstd_tar, find_installed_by_registration_id, + load_install_display_icon, read_bundle_from_exe, uninstall_fromchat, wipe_install_dir, + write_install_icon, FromChatEdition, HelperCommand, InstalledFromChat, ProgressEvent, + BRANDING_PNG, SETUP_MAGIC, UNINSTALL_SETUP_EXE, VISIBLE_PORTABLE_EXE, +}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Screen { + Welcome = 0, + InstallOptions = 1, + PortableOptions = 2, + UninstallConfirm = 3, + Progress = 4, + Done = 5, + UninstallDone = 6, + UpgradeConfirm = 7, +} + +#[cfg(windows)] +struct ElevatedInstallSession { + pipe: fromchat_setup_common::NamedPipeServer, + cmd_json: String, +} + +pub struct SetupApp { + mode: SetupMode, + window_title: &'static str, + screen: Screen, + transition: Option>, + time: f32, + logo: TextureHandle, + brand_bg: TextureHandle, + install_icon_textures: HashMap, + install_path: String, + portable_path: String, + all_users: bool, + desktop_shortcut: bool, + was_portable: bool, + progress: Lerped, + status: String, + status_alpha: f32, + status_pending: Option, + launch_path: Option, + error: Option, + progress_rx: Option>, + #[cfg(windows)] + elevation_rx: Option>>, + #[cfg(windows)] + installed: Option, + preserve_user_data: bool, + silent_upgrade: bool, + pending_auto_upgrade: bool, + is_uninstalling: bool, + is_upgrading: bool, + version: String, + logged_first_frame: bool, + applied_window_chrome: bool, +} + +impl SetupApp { + pub fn new(cc: &eframe::CreationContext<'_>, options: SetupOptions) -> Self { + crate::win_log::info("SetupApp::new: style"); + let mut style = (*cc.egui_ctx.style()).clone(); + style.visuals.dark_mode = true; + style.visuals.panel_fill = theme::SURFACE; + style.visuals.window_fill = theme::SURFACE; + style.visuals.override_text_color = Some(ON_SURFACE); + cc.egui_ctx.set_style(style); + + crate::win_log::info("SetupApp::new: fonts"); + fonts::install(&cc.egui_ctx); + crate::win_log::info("SetupApp::new: logo texture"); + let logo = load_logo_texture(&cc.egui_ctx); + crate::win_log::info("SetupApp::new: brand bg texture"); + let brand_bg = load_brand_bg_texture(&cc.egui_ctx); + + crate::win_log::info("SetupApp::new: read bundle version"); + let bundle = read_bundle_from_exe( + &std::env::current_exe().unwrap_or_default(), + SETUP_MAGIC, + ); + let version = bundle + .as_ref() + .map(|b| b.meta.version.clone()) + .unwrap_or_else(|error| { + crate::win_log::warn(&format!("bundle version read failed: {error:#}")); + env!("CARGO_PKG_VERSION").to_string() + }); + let registration_id = bundle + .as_ref() + .map(|b| b.meta.registration_id.clone()) + .unwrap_or_else(|_| "FromChat".to_owned()); + + let install_path = default_install_path(false); + let portable_path = std::env::current_dir() + .map(|p| p.join("FromChat").to_string_lossy().into_owned()) + .unwrap_or_else(|_| r".\FromChat".into()); + + #[cfg(windows)] + let installed = find_installed_by_registration_id(®istration_id); + #[cfg(not(windows))] + let installed = None; + + let needs_installed = matches!(options.mode, SetupMode::Uninstall); + let error = if needs_installed && installed.is_none() { + Some(i18n::NOT_INSTALLED.into()) + } else { + None + }; + + let window_title = crate::args::window_title_for_mode(options.mode); + + let silent_upgrade = matches!(options.mode, SetupMode::Upgrade); + let pending_auto_upgrade = silent_upgrade; + + let screen = match options.mode { + SetupMode::Uninstall => Screen::UninstallConfirm, + SetupMode::Upgrade => Screen::Progress, + SetupMode::Install => Screen::Welcome, + }; + + Self { + mode: options.mode, + window_title, + screen, + transition: None, + time: 0.0, + logo, + brand_bg, + install_icon_textures: HashMap::new(), + install_path, + portable_path, + all_users: false, + desktop_shortcut: true, + was_portable: false, + progress: Lerped::new(0.0), + status: String::new(), + status_alpha: 1.0, + status_pending: None, + launch_path: None, + error, + progress_rx: None, + #[cfg(windows)] + elevation_rx: None, + #[cfg(windows)] + installed, + preserve_user_data: true, + silent_upgrade, + pending_auto_upgrade, + is_uninstalling: matches!(options.mode, SetupMode::Uninstall), + is_upgrading: silent_upgrade, + version, + logged_first_frame: false, + applied_window_chrome: false, + } + } + + fn should_confirm_close(&self) -> bool { + !self.silent_upgrade && (self.screen == Screen::Progress || self.elevation_pending()) + } + + fn request_close(&mut self, ctx: &egui::Context, frame: &eframe::Frame) { + if self.should_confirm_close() { + #[cfg(windows)] + { + let hwnd = crate::win_dialog::hwnd_from_frame(frame); + if crate::win_dialog::confirm_close(hwnd) { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + return; + } + } + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + + fn operation_error_title(&self) -> &'static str { + if self.is_uninstalling { + i18n::UNINSTALL_ERROR_TITLE + } else if self.is_upgrading || self.silent_upgrade { + i18n::UPGRADE_ERROR_TITLE + } else { + i18n::INSTALL_ERROR_TITLE + } + } + + fn handle_fatal_operation_error( + &self, + ctx: &egui::Context, + frame: &eframe::Frame, + message: &str, + ) { + #[cfg(windows)] + { + let hwnd = crate::win_dialog::hwnd_from_frame(frame); + crate::win_dialog::show_fatal_operation_error( + hwnd, + self.operation_error_title(), + message, + ); + } + #[cfg(not(windows))] + { + let _ = (ctx, frame, message); + } + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + std::process::exit(1); + } + + fn go(&mut self, to: Screen) { + if self.transition.is_some() || self.screen == to { + return; + } + if self.silent_upgrade + && matches!(to, Screen::UpgradeConfirm | Screen::Done | Screen::Welcome) + { + return; + } + self.transition = Some(ScreenTransition::start(self.screen, to)); + } + + fn draw_screen(&mut self, ui: &mut Ui, screen: Screen, hover_enabled: bool) { + // Per-screen Id namespace so hover temps never bleed across slides. + ui.push_id(screen as u8, |ui| { + match screen { + Screen::Welcome => self.ui_welcome(ui, hover_enabled), + Screen::InstallOptions => { + self.ui_install_options(ui, hover_enabled && !self.elevation_pending()) + } + Screen::PortableOptions => self.ui_portable_options(ui, hover_enabled), + Screen::Progress => self.ui_progress(ui), + Screen::Done => self.ui_done(ui, hover_enabled), + Screen::UninstallDone => self.ui_uninstall_done(ui, hover_enabled), + Screen::UninstallConfirm => { + self.ui_uninstall_confirm(ui, hover_enabled && !self.elevation_pending()) + } + Screen::UpgradeConfirm => { + self.ui_upgrade_confirm(ui, hover_enabled && !self.elevation_pending()) + } + } + }); + } +} + +impl eframe::App for SetupApp { + fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + if !self.applied_window_chrome { + self.applied_window_chrome = true; + crate::win_chrome::apply(frame); + } + if !self.logged_first_frame { + self.logged_first_frame = true; + crate::win_log::info("first UI frame"); + } + let dt = ctx.input(|i| i.stable_dt).min(0.05); + self.time += dt; + + if let Some(t) = self.transition.as_mut() { + if t.tick(dt) { + self.screen = t.to; + self.transition = None; + } + ctx.request_repaint(); + } + + self.progress.tick(dt); + if let Some(pending) = self.status_pending.clone() { + self.status_alpha = (self.status_alpha - dt * 6.0).max(0.0); + if self.status_alpha <= 0.01 { + self.status = pending; + self.status_pending = None; + self.status_alpha = 0.0; + } + ctx.request_repaint(); + } else if self.status_alpha < 1.0 { + self.status_alpha = (self.status_alpha + dt * 6.0).min(1.0); + ctx.request_repaint(); + } + + + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + + #[cfg(windows)] + if let Some(rx) = &self.elevation_rx { + match rx.try_recv() { + Ok(Some(session)) => { + self.elevation_rx = None; + self.launch_progress_elevated(session); + } + Ok(None) => { + self.elevation_rx = None; + if self.silent_upgrade || self.is_upgrading || self.is_uninstalling { + self.handle_fatal_operation_error(ctx, frame, i18n::ELEVATION_DENIED); + } + } + Err(mpsc::TryRecvError::Empty) => { + ctx.request_repaint(); + } + Err(mpsc::TryRecvError::Disconnected) => { + self.elevation_rx = None; + } + } + } + + if self.pending_auto_upgrade { + self.pending_auto_upgrade = false; + self.start_upgrade(); + } + + if let Some(rx) = &self.progress_rx { + let mut events = Vec::new(); + while let Ok(ev) = rx.try_recv() { + events.push(ev); + } + for ev in events { + match ev { + ProgressEvent::Status { message } => { + self.status_pending = Some(message); + self.status_alpha = 1.0; + } + ProgressEvent::Progress { fraction } => { + self.progress.set_target(fraction.clamp(0.0, 1.0)); + } + ProgressEvent::Done { launch_path } => { + if self.silent_upgrade { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } else { + self.launch_path = Some(PathBuf::from(launch_path)); + self.progress.set_target(1.0); + self.go(Screen::Done); + } + } + ProgressEvent::Uninstalled => { + self.progress.set_target(1.0); + self.go(Screen::UninstallDone); + } + ProgressEvent::Error { message } => { + crate::win_log::error(&message); + if self.screen == Screen::Progress { + self.handle_fatal_operation_error(ctx, frame, &message); + } else { + self.error = Some(message); + } + } + } + } + } + + egui::CentralPanel::default() + .frame(egui::Frame::none()) + .show(ctx, |ui| { + let full = ui.max_rect(); + theme::draw_background(ui.painter(), full); + + let welcome_visible = self.screen == Screen::Welcome + || self + .transition + .is_some_and(|t| t.from == Screen::Welcome || t.to == Screen::Welcome); + + ui.allocate_ui_at_rect(full, |ui| { + ui.set_clip_rect(full); + if let Some(tr) = self.transition { + let w = full.width(); + let e = tr.eased(); + let forward = (tr.to as i32) > (tr.from as i32); + let (out_x, in_x) = if forward { + (-e * w, (1.0 - e) * w) + } else { + (e * w, -(1.0 - e) * w) + }; + let from = tr.from; + let to = tr.to; + ui.allocate_ui_at_rect(full.translate(Vec2::new(out_x, 0.0)), |ui| { + self.draw_screen(ui, from, false); + }); + ui.allocate_ui_at_rect(full.translate(Vec2::new(in_x, 0.0)), |ui| { + // Hover off during slide; clicks still register. + self.draw_screen(ui, to, false); + }); + } else { + self.draw_screen(ui, self.screen, true); + } + }); + + // Always paint chrome at the physical top of the window (never allocate in flow). + let mut close_requested = false; + let mut minimize_requested = false; + draw_title_bar( + ui, + full, + welcome_visible, + &self.logo, + ctx.input(|i| i.focused), + self.window_title, + &mut || close_requested = true, + &mut || minimize_requested = true, + ); + if minimize_requested { + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)); + } + if close_requested { + self.request_close(ctx, frame); + } + }); + } +} + +impl SetupApp { + fn ui_welcome(&mut self, ui: &mut Ui, hover_enabled: bool) { + let full = ui.max_rect(); + + ui.painter().image( + self.brand_bg.id(), + full, + Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), + Color32::WHITE, + ); + draw_welcome_brand_overlay(ui.painter(), full, &self.logo); + + let card_w = (full.width() - H_PADDING * 2.0).max(1.0); + let installed = self.installed.is_some(); + let gap = 8.0; + let version_gap = 24.0; + let version_h = 18.0; + + let upgrade_h = if installed { + widgets::choice_card_height(card_w, i18n::UPGRADE_DESC, ui.painter()) + } else { + 0.0 + }; + let primary_title = if installed { + i18n::DELETE + } else { + i18n::INSTALL + }; + let primary_desc = if installed { + i18n::DELETE_DESC + } else { + i18n::INSTALL_DESC + }; + let primary_icon = if installed { DELETE } else { DOWNLOAD }; + let primary_h = widgets::choice_card_height(card_w, primary_desc, ui.painter()); + let portable_h = + widgets::choice_card_height(card_w, i18n::PORTABLE_DESC, ui.painter()); + + let block_h = (if installed { upgrade_h + gap } else { 0.0 }) + + primary_h + + gap + + portable_h + + version_gap + + version_h; + let left = full.left() + H_PADDING; + let mut y = full.bottom() - H_PADDING - block_h; + + let upgrade_rect = if installed { + let rect = Rect::from_min_size(Pos2::new(left, y), Vec2::new(card_w, upgrade_h)); + y += upgrade_h + gap; + Some(rect) + } else { + None + }; + let primary_rect = + Rect::from_min_size(Pos2::new(left, y), Vec2::new(card_w, primary_h)); + y += primary_h + gap; + let portable_rect = + Rect::from_min_size(Pos2::new(left, y), Vec2::new(card_w, portable_h)); + y += portable_h + version_gap; + let version_rect = + Rect::from_min_size(Pos2::new(left, y), Vec2::new(card_w, version_h)); + + if let Some(upgrade_rect) = upgrade_rect { + ui.allocate_ui_at_rect(upgrade_rect, |ui| { + ui.set_clip_rect(upgrade_rect); + if widgets::choice_card_sized( + ui, + card_w, + i18n::UPGRADE, + i18n::UPGRADE_DESC, + SYSTEM_UPGRADE_ALT, + hover_enabled, + ) && !self.silent_upgrade { + self.go(Screen::UpgradeConfirm); + } + }); + } + ui.allocate_ui_at_rect(primary_rect, |ui| { + ui.set_clip_rect(primary_rect); + if widgets::choice_card_sized( + ui, + card_w, + primary_title, + primary_desc, + primary_icon, + hover_enabled, + ) { + if installed { + self.go(Screen::UninstallConfirm); + } else { + self.go(Screen::InstallOptions); + } + } + }); + ui.allocate_ui_at_rect(portable_rect, |ui| { + ui.set_clip_rect(portable_rect); + if widgets::choice_card_sized( + ui, + card_w, + i18n::PORTABLE, + i18n::PORTABLE_DESC, + FOLDER, + hover_enabled, + ) { + self.go(Screen::PortableOptions); + } + }); + ui.allocate_ui_at_rect(version_rect, |ui| { + ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { + ui.set_width(card_w); + ui.label( + egui::RichText::new(i18n::version_label(&self.version)) + .font(body(12.0)) + .color(ON_SURFACE_VARIANT), + ); + }); + }); + } + + fn ui_install_options(&mut self, ui: &mut Ui, hover_enabled: bool) { + let full = ui.max_rect(); + let bar_rect = Rect::from_min_max( + Pos2::new(full.left(), full.bottom() - widgets::BOTTOM_BAR_HEIGHT), + full.right_bottom(), + ); + let body_rect = Rect::from_min_max( + full.left_top(), + Pos2::new(full.right(), bar_rect.top()), + ); + + ui.allocate_ui_at_rect(body_rect, |ui| { + ui.set_clip_rect(body_rect); + ui.add_space(TITLE_BAR_HEIGHT + 4.0); + egui::Frame::none() + .inner_margin(egui::Margin { + left: widgets::H_PADDING, + right: widgets::H_PADDING, + ..Default::default() + }) + .show(ui, |ui| { + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + widgets::expressive_section_header( + ui, + i18n::INSTALL, + DOWNLOAD, + widgets::ExpressiveHeroShape::Cookie4Sided, + ); + widgets::field_label(ui, i18n::INSTALL_LOCATION); + if widgets::path_field( + ui, + &mut self.install_path, + hover_enabled, + ui.id().with("install_path"), + ) { + #[cfg(windows)] + if let Some(path) = rfd::FileDialog::new().pick_folder() { + self.install_path = path.to_string_lossy().into_owned(); + } + } + ui.add_space(8.0); + let was_all_users = self.all_users; + widgets::checkbox(ui, &mut self.all_users, i18n::ALL_USERS, hover_enabled); + if self.all_users != was_all_users { + self.install_path = default_install_path(self.all_users); + } + widgets::checkbox( + ui, + &mut self.desktop_shortcut, + i18n::DESKTOP, + hover_enabled, + ); + if let Some(err) = &self.error { + widgets::error_label(ui, err); + } + }); + }); + }); + + let mut back = false; + let mut go_next = false; + ui.allocate_ui_at_rect(bar_rect, |ui| { + ui.set_clip_rect(bar_rect); + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + egui::Frame::none() + .inner_margin(egui::Margin::symmetric( + widgets::H_PADDING, + widgets::BOTTOM_BAR_PAD, + )) + .show(ui, |ui| { + ui.set_height(widgets::BOTTOM_BAR_CONTROL_HEIGHT); + widgets::bottom_bar( + ui, + hover_enabled, + true, + || back = true, + || go_next = true, + widgets::PrimaryAction::Install, + ); + }); + }); + }); + if back { + self.go(Screen::Welcome); + } + if go_next { + self.start_install(false); + } + } + + fn ui_portable_options(&mut self, ui: &mut Ui, hover_enabled: bool) { + let full = ui.max_rect(); + let bar_rect = Rect::from_min_max( + Pos2::new(full.left(), full.bottom() - widgets::BOTTOM_BAR_HEIGHT), + full.right_bottom(), + ); + let body_rect = Rect::from_min_max( + full.left_top(), + Pos2::new(full.right(), bar_rect.top()), + ); + + ui.allocate_ui_at_rect(body_rect, |ui| { + ui.set_clip_rect(body_rect); + ui.add_space(TITLE_BAR_HEIGHT + 4.0); + egui::Frame::none() + .inner_margin(egui::Margin { + left: widgets::H_PADDING, + right: widgets::H_PADDING, + ..Default::default() + }) + .show(ui, |ui| { + widgets::expressive_section_header( + ui, + i18n::PORTABLE, + FOLDER, + widgets::ExpressiveHeroShape::Cookie6Sided, + ); + widgets::field_label(ui, i18n::DESTINATION); + if widgets::path_field( + ui, + &mut self.portable_path, + hover_enabled, + ui.id().with("portable_path"), + ) { + #[cfg(windows)] + if let Some(path) = rfd::FileDialog::new().pick_folder() { + self.portable_path = path.to_string_lossy().into_owned(); + } + } + if let Some(err) = &self.error { + widgets::error_label(ui, err); + } + }); + }); + + let mut back = false; + let mut go_next = false; + ui.allocate_ui_at_rect(bar_rect, |ui| { + ui.set_clip_rect(bar_rect); + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + egui::Frame::none() + .inner_margin(egui::Margin::symmetric( + widgets::H_PADDING, + widgets::BOTTOM_BAR_PAD, + )) + .show(ui, |ui| { + ui.set_height(widgets::BOTTOM_BAR_CONTROL_HEIGHT); + widgets::bottom_bar( + ui, + hover_enabled, + true, + || back = true, + || go_next = true, + widgets::PrimaryAction::Extract, + ); + }); + }); + }); + if back { + self.go(Screen::Welcome); + } + if go_next { + self.start_install(true); + } + } + + fn ui_uninstall_confirm(&mut self, ui: &mut Ui, hover_enabled: bool) { + let full = ui.max_rect(); + let bar_rect = Rect::from_min_max( + Pos2::new(full.left(), full.bottom() - widgets::BOTTOM_BAR_HEIGHT), + full.right_bottom(), + ); + let body_rect = Rect::from_min_max( + full.left_top(), + Pos2::new(full.right(), bar_rect.top()), + ); + + ui.allocate_ui_at_rect(body_rect, |ui| { + ui.set_clip_rect(body_rect); + ui.add_space(TITLE_BAR_HEIGHT + 4.0); + egui::Frame::none() + .inner_margin(egui::Margin { + left: widgets::H_PADDING, + right: widgets::H_PADDING, + ..Default::default() + }) + .show(ui, |ui| { + widgets::expressive_section_header( + ui, + i18n::UNINSTALL_TITLE, + DELETE, + widgets::ExpressiveHeroShape::Cookie6Sided, + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new(i18n::UNINSTALL_CONFIRM_BODY) + .font(body(14.0)) + .color(ON_SURFACE_VARIANT), + ); + ui.add_space(12.0); + widgets::checkbox( + ui, + &mut self.preserve_user_data, + i18n::PRESERVE_USER_DATA, + hover_enabled, + ); + ui.add_space(12.0); + ui.push_id("support", |ui| { + widgets::done_list_row(ui, SUPPORT, |ui| { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.label( + egui::RichText::new(i18n::UNINSTALL_SUPPORT_PREFIX) + .font(body(14.0)) + .color(ON_SURFACE_VARIANT), + ); + if widgets::inline_link( + ui, + i18n::UNINSTALL_SUPPORT_LABEL, + hover_enabled, + ) { + open_url(i18n::DONE_TELEGRAM_SUPPORT_URL); + } + ui.label( + egui::RichText::new(".") + .font(body(14.0)) + .color(ON_SURFACE_VARIANT), + ); + }); + }); + }); + if let Some(err) = &self.error { + widgets::error_label(ui, err); + } + }); + }); + + let mut back = false; + let mut go_next = false; + ui.allocate_ui_at_rect(bar_rect, |ui| { + ui.set_clip_rect(bar_rect); + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + egui::Frame::none() + .inner_margin(egui::Margin::symmetric( + widgets::H_PADDING, + widgets::BOTTOM_BAR_PAD, + )) + .show(ui, |ui| { + ui.set_height(widgets::BOTTOM_BAR_CONTROL_HEIGHT); + widgets::bottom_bar( + ui, + hover_enabled, + !matches!(self.mode, SetupMode::Uninstall), + || back = true, + || go_next = true, + widgets::PrimaryAction::Uninstall, + ); + }); + }); + }); + if back { + self.go(Screen::Welcome); + } + if go_next { + self.start_uninstall(); + } + } + + fn ensure_install_icon_texture(&mut self, ctx: &egui::Context, installed: &InstalledFromChat) { + let key = installed.install_dir.to_string_lossy().into_owned(); + if self.install_icon_textures.contains_key(&key) { + return; + } + let Some(icon) = load_install_display_icon( + installed.display_icon.as_deref(), + &installed.install_dir, + ) else { + return; + }; + let size = [icon.width as usize, icon.height as usize]; + let color_image = egui::ColorImage::from_rgba_unmultiplied(size, &icon.pixels); + let tex = ctx.load_texture( + format!("install-icon-{key}"), + color_image, + egui::TextureOptions::LINEAR, + ); + self.install_icon_textures.insert(key, tex); + } + + fn ui_upgrade_confirm(&mut self, ui: &mut Ui, hover_enabled: bool) { + let ctx = ui.ctx().clone(); + let full = ui.max_rect(); + let bar_rect = Rect::from_min_max( + Pos2::new(full.left(), full.bottom() - widgets::BOTTOM_BAR_HEIGHT), + full.right_bottom(), + ); + let body_rect = Rect::from_min_max( + full.left_top(), + Pos2::new(full.right(), bar_rect.top()), + ); + let installed = self.installed.clone(); + + ui.allocate_ui_at_rect(body_rect, |ui| { + ui.set_clip_rect(body_rect); + ui.add_space(TITLE_BAR_HEIGHT + 4.0); + egui::Frame::none() + .inner_margin(egui::Margin { + left: widgets::H_PADDING, + right: widgets::H_PADDING, + ..Default::default() + }) + .show(ui, |ui| { + widgets::expressive_section_header( + ui, + i18n::UPGRADE_CONFIRM_TITLE, + SYSTEM_UPGRADE_ALT, + widgets::ExpressiveHeroShape::Cookie6Sided, + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new(i18n::UPGRADE_CONFIRM_BODY) + .font(body(14.0)) + .color(ON_SURFACE_VARIANT), + ); + if let Some(installed) = &installed { + self.ensure_install_icon_texture(&ctx, installed); + let icon_key = installed.install_dir.to_string_lossy().into_owned(); + let icon_texture = self.install_icon_textures.get(&icon_key); + ui.add_space(12.0); + widgets::install_copy_card( + ui, + &i18n::installed_copy_title( + installed.edition.display_name(), + installed.version.as_deref(), + ), + &installed.install_dir.to_string_lossy(), + icon_texture, + SYSTEM_UPGRADE_ALT, + ); + } else if self.error.is_none() { + self.error = Some(i18n::NOT_INSTALLED.into()); + } + if let Some(err) = &self.error { + widgets::error_label(ui, err); + } + }); + }); + + let mut back = false; + let mut go_next = false; + let can_upgrade = installed.is_some(); + ui.allocate_ui_at_rect(bar_rect, |ui| { + ui.set_clip_rect(bar_rect); + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + egui::Frame::none() + .inner_margin(egui::Margin::symmetric( + widgets::H_PADDING, + widgets::BOTTOM_BAR_PAD, + )) + .show(ui, |ui| { + ui.set_height(widgets::BOTTOM_BAR_CONTROL_HEIGHT); + widgets::bottom_bar( + ui, + hover_enabled && can_upgrade, + !matches!(self.mode, SetupMode::Uninstall | SetupMode::Upgrade), + || back = true, + || go_next = true, + widgets::PrimaryAction::Upgrade, + ); + }); + }); + }); + if back { + self.go(Screen::Welcome); + } + if go_next && can_upgrade { + self.start_upgrade(); + } + } + + fn ui_progress(&mut self, ui: &mut Ui) { + ui.add_space(TITLE_BAR_HEIGHT); + ui.vertical_centered(|ui| { + ui.add_space(48.0); + draw_logo(ui, &self.logo, 64.0); + ui.add_space(16.0); + let title = if self.is_upgrading { + i18n::UPGRADING + } else if self.is_uninstalling { + i18n::UNINSTALLING + } else { + i18n::INSTALLING + }; + ui.label( + egui::RichText::new(title) + .font(title_large()) + .color(ON_SURFACE), + ); + ui.add_space(20.0); + let bar_w = ui.available_width().min(360.0); + let bar_h = theme::LINEAR_PROGRESS_HEIGHT; + let (rect, _) = ui.allocate_exact_size(Vec2::new(bar_w, bar_h), Sense::hover()); + ui.painter().rect_filled( + rect, + Rounding::same(theme::LINEAR_PROGRESS_CORNER_RADIUS), + theme::SURFACE_CONTAINER_HIGHEST, + ); + let fill = Rect::from_min_size( + rect.min, + Vec2::new(rect.width() * self.progress.value, bar_h), + ); + ui.painter().rect_filled( + fill, + Rounding::same(theme::LINEAR_PROGRESS_CORNER_RADIUS), + PRIMARY, + ); + ui.add_space(16.0); + ui.label( + egui::RichText::new(&self.status) + .font(body_medium(14.0)) + .color(ON_SURFACE_VARIANT.gamma_multiply(self.status_alpha)), + ); + if let Some(err) = &self.error { + widgets::error_label(ui, err); + } + }); + } + + fn ui_done(&mut self, ui: &mut Ui, hover_enabled: bool) { + let full = ui.max_rect(); + let bar_rect = Rect::from_min_max( + Pos2::new(full.left(), full.bottom() - widgets::BOTTOM_BAR_HEIGHT), + full.right_bottom(), + ); + let body_rect = Rect::from_min_max( + full.left_top(), + Pos2::new(full.right(), bar_rect.top()), + ); + + ui.allocate_ui_at_rect(body_rect, |ui| { + ui.set_clip_rect(body_rect); + ui.add_space(TITLE_BAR_HEIGHT + 4.0); + egui::Frame::none() + .inner_margin(egui::Margin { + left: widgets::H_PADDING, + right: widgets::H_PADDING, + ..Default::default() + }) + .show(ui, |ui| { + widgets::expressive_section_header( + ui, + i18n::DONE_TITLE, + DONE_ALL, + widgets::ExpressiveHeroShape::Cookie6Sided, + ); + + let hint = if self.was_portable { + i18n::DONE_OPEN_HINT_PORTABLE + } else if self.desktop_shortcut { + i18n::DONE_OPEN_HINT_DESKTOP + } else { + i18n::DONE_OPEN_HINT + }; + ui.push_id("open_hint", |ui| { + widgets::done_list_row(ui, OPEN_IN_NEW, |ui| { + ui.label( + egui::RichText::new(hint) + .font(body(14.0)) + .color(ON_SURFACE), + ); + }); + }); + ui.add_space(12.0); + ui.push_id("telegram", |ui| { + widgets::done_list_row(ui, RSS_FEED, |ui| { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.label( + egui::RichText::new(i18n::DONE_TELEGRAM_PREFIX) + .font(body(14.0)) + .color(ON_SURFACE_VARIANT), + ); + if widgets::inline_link( + ui, + i18n::DONE_TELEGRAM_CHANNEL_LABEL, + hover_enabled, + ) { + open_url(i18n::DONE_TELEGRAM_CHANNEL_URL); + } + ui.label( + egui::RichText::new(i18n::DONE_TELEGRAM_MIDDLE) + .font(body(14.0)) + .color(ON_SURFACE_VARIANT), + ); + if widgets::inline_link( + ui, + i18n::DONE_TELEGRAM_SUPPORT_LABEL, + hover_enabled, + ) { + open_url(i18n::DONE_TELEGRAM_SUPPORT_URL); + } + ui.label( + egui::RichText::new(".") + .font(body(14.0)) + .color(ON_SURFACE_VARIANT), + ); + }); + }); + }); + }); + }); + + let ctx = ui.ctx().clone(); + ui.allocate_ui_at_rect(bar_rect, |ui| { + ui.set_clip_rect(bar_rect); + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + egui::Frame::none() + .inner_margin(egui::Margin::symmetric( + widgets::H_PADDING, + widgets::BOTTOM_BAR_PAD, + )) + .show(ui, |ui| { + ui.set_height(widgets::BOTTOM_BAR_CONTROL_HEIGHT); + let (launch, close) = widgets::done_bottom_bar(ui, hover_enabled); + if launch { + if let Some(p) = &self.launch_path { + let _ = std::process::Command::new(p).spawn(); + } + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + if close { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + }); + }); + } + + fn ui_uninstall_done(&mut self, ui: &mut Ui, hover_enabled: bool) { + let full = ui.max_rect(); + let bar_rect = Rect::from_min_max( + Pos2::new(full.left(), full.bottom() - widgets::BOTTOM_BAR_HEIGHT), + full.right_bottom(), + ); + let body_rect = Rect::from_min_max( + full.left_top(), + Pos2::new(full.right(), bar_rect.top()), + ); + + ui.allocate_ui_at_rect(body_rect, |ui| { + ui.set_clip_rect(body_rect); + ui.add_space(TITLE_BAR_HEIGHT + 4.0); + egui::Frame::none() + .inner_margin(egui::Margin { + left: widgets::H_PADDING, + right: widgets::H_PADDING, + ..Default::default() + }) + .show(ui, |ui| { + widgets::expressive_section_header( + ui, + i18n::UNINSTALL_DONE_TITLE, + DONE_ALL, + widgets::ExpressiveHeroShape::Cookie6Sided, + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new(i18n::UNINSTALL_DONE_SUBTITLE) + .font(body(14.0)) + .color(ON_SURFACE_VARIANT), + ); + }); + }); + + let ctx = ui.ctx().clone(); + ui.allocate_ui_at_rect(bar_rect, |ui| { + ui.set_clip_rect(bar_rect); + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + egui::Frame::none() + .inner_margin(egui::Margin::symmetric( + widgets::H_PADDING, + widgets::BOTTOM_BAR_PAD, + )) + .show(ui, |ui| { + ui.set_height(widgets::BOTTOM_BAR_CONTROL_HEIGHT); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.set_width(ui.available_width()); + if widgets::text_button_icon(ui, CLOSE, i18n::CLOSE, hover_enabled) { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + }); + }); + }); + } + + fn elevation_pending(&self) -> bool { + #[cfg(windows)] + { + return self.elevation_rx.is_some(); + } + #[cfg(not(windows))] + { + false + } + } + + fn launch_progress_elevated(&mut self, session: ElevatedInstallSession) { + self.go(Screen::Progress); + self.status = i18n::PREPARING.into(); + self.progress = Lerped::new(0.0); + let (tx, rx) = mpsc::channel(); + self.progress_rx = Some(rx); + thread::spawn(move || { + if let Err(error) = run_elevated_install(session, tx.clone()) { + let _ = tx.send(ProgressEvent::Error { + message: format!("{error:#}"), + }); + } + }); + } + + fn launch_standard_install(&mut self, portable: bool) { + self.go(Screen::Progress); + self.status = i18n::PREPARING.into(); + self.progress = Lerped::new(0.0); + let (tx, rx) = mpsc::channel(); + self.progress_rx = Some(rx); + + let install_path = self.install_path.clone(); + let portable_path = self.portable_path.clone(); + let all_users = self.all_users; + let desktop_shortcut = self.desktop_shortcut; + + thread::spawn(move || { + let result = if portable { + run_portable(tx.clone(), PathBuf::from(portable_path)) + } else { + run_full_install( + tx.clone(), + PathBuf::from(install_path), + all_users, + desktop_shortcut, + ) + }; + if let Err(error) = result { + let _ = tx.send(ProgressEvent::Error { + message: format!("{error:#}"), + }); + } + }); + } + + fn start_install(&mut self, portable: bool) { + self.error = None; + self.was_portable = portable; + self.is_uninstalling = false; + self.is_upgrading = false; + + #[cfg(windows)] + if !portable && self.all_users && !fromchat_setup_common::is_elevated() { + let install_path = PathBuf::from(&self.install_path); + let desktop_shortcut = self.desktop_shortcut; + let (tx, rx) = mpsc::channel(); + self.elevation_rx = Some(rx); + thread::spawn(move || { + let session = begin_elevated_install(install_path.as_path(), desktop_shortcut).ok(); + let _ = tx.send(session); + }); + return; + } + + #[cfg(not(windows))] + if !portable && self.all_users { + return; + } + + self.launch_standard_install(portable); + } + + fn launch_standard_uninstall(&mut self, installed: InstalledFromChat) { + self.go(Screen::Progress); + self.status = i18n::PREPARING.into(); + self.progress = Lerped::new(0.0); + let preserve_user_data = self.preserve_user_data; + let (tx, rx) = mpsc::channel(); + self.progress_rx = Some(rx); + thread::spawn(move || { + if let Err(error) = run_uninstall(tx.clone(), installed, preserve_user_data) { + let _ = tx.send(ProgressEvent::Error { + message: format!("{error:#}"), + }); + } + }); + } + + fn launch_standard_upgrade(&mut self, installed: InstalledFromChat) { + self.go(Screen::Progress); + self.status = i18n::PREPARING.into(); + self.progress = Lerped::new(0.0); + let (tx, rx) = mpsc::channel(); + self.progress_rx = Some(rx); + thread::spawn(move || { + if let Err(error) = run_upgrade(tx.clone(), installed) { + let _ = tx.send(ProgressEvent::Error { + message: format!("{error:#}"), + }); + } + }); + } + + fn start_uninstall(&mut self) { + self.error = None; + self.is_uninstalling = true; + self.is_upgrading = false; + + #[cfg(windows)] + let installed = self.installed.clone(); + #[cfg(not(windows))] + let installed = None; + + let Some(installed) = installed else { + self.error = Some(i18n::NOT_INSTALLED.into()); + return; + }; + + let preserve_user_data = self.preserve_user_data; + + #[cfg(windows)] + if installed.all_users && !fromchat_setup_common::is_elevated() { + let (tx, rx) = mpsc::channel(); + self.elevation_rx = Some(rx); + thread::spawn(move || { + let session = + begin_elevated_uninstall(&installed, preserve_user_data).ok(); + let _ = tx.send(session); + }); + return; + } + + self.launch_standard_uninstall(installed); + } + + fn start_upgrade(&mut self) { + self.error = None; + self.is_uninstalling = false; + self.is_upgrading = true; + + #[cfg(windows)] + let installed = self.installed.clone(); + #[cfg(not(windows))] + let installed = None; + + let Some(installed) = installed else { + self.error = Some(i18n::NOT_INSTALLED.into()); + return; + }; + + #[cfg(windows)] + if installed.all_users && !fromchat_setup_common::is_elevated() { + let (tx, rx) = mpsc::channel(); + self.elevation_rx = Some(rx); + thread::spawn(move || { + let session = begin_elevated_upgrade(&installed).ok(); + let _ = tx.send(session); + }); + return; + } + + self.launch_standard_upgrade(installed); + } +} + +fn load_brand_bg_texture(ctx: &egui::Context) -> TextureHandle { + const BRAND_BG_PNG: &[u8] = include_bytes!("../../assets/welcome_brand_bg.png"); + let color_image = match image::load_from_memory(BRAND_BG_PNG) { + Ok(image) => { + let rgba = image.to_rgba8(); + let size = [rgba.width() as usize, rgba.height() as usize]; + egui::ColorImage::from_rgba_unmultiplied(size, rgba.as_raw()) + } + Err(error) => { + crate::win_log::error(&format!("welcome brand bg png decode failed: {error}")); + egui::ColorImage::new([1, 1], theme::SURFACE) + } + }; + ctx.load_texture( + "fromchat-welcome-brand-bg", + color_image, + egui::TextureOptions::LINEAR, + ) +} + +fn open_url(url: &str) { + #[cfg(windows)] + { + let _ = std::process::Command::new("cmd") + .args(["/C", "start", "", url]) + .spawn(); + } + #[cfg(not(windows))] + { + let _ = url; + } +} + +fn load_logo_texture(ctx: &egui::Context) -> TextureHandle { + let color_image = match image::load_from_memory(BRANDING_PNG) { + Ok(image) => { + let rgba = image.to_rgba8(); + let size = [rgba.width() as usize, rgba.height() as usize]; + egui::ColorImage::from_rgba_unmultiplied(size, rgba.as_raw()) + } + Err(error) => { + crate::win_log::error(&format!("branding png decode failed: {error}")); + egui::ColorImage::new([64, 64], theme::PRIMARY_CONTAINER) + } + }; + ctx.load_texture( + "fromchat-logo", + color_image, + egui::TextureOptions::LINEAR, + ) +} + +fn run_portable(tx: Sender, dest: PathBuf) -> anyhow::Result<()> { + let _ = tx.send(ProgressEvent::Status { + message: i18n::READING_PACKAGE.into(), + }); + let exe = std::env::current_exe()?; + let bundle = read_bundle_from_exe(&exe, SETUP_MAGIC)?; + let _ = tx.send(ProgressEvent::Progress { fraction: 0.15 }); + std::fs::create_dir_all(&dest)?; + let _ = tx.send(ProgressEvent::Status { + message: i18n::EXTRACTING.into(), + }); + extract_zstd_tar(&bundle.payload_zstd, &dest)?; + let _ = tx.send(ProgressEvent::Progress { fraction: 0.7 }); + let launcher_path = dest.join(VISIBLE_PORTABLE_EXE); + std::fs::write(&launcher_path, &bundle.launcher)?; + let _ = tx.send(ProgressEvent::Status { + message: i18n::HIDING_FILES.into(), + }); + #[cfg(windows)] + { + fromchat_setup_common::hide_all_except(&dest, VISIBLE_PORTABLE_EXE)?; + let data = dest.join("fromchat-data"); + std::fs::create_dir_all(&data)?; + fromchat_setup_common::set_hidden(&data, true)?; + } + let _ = tx.send(ProgressEvent::Progress { fraction: 1.0 }); + let _ = tx.send(ProgressEvent::Done { + launch_path: launcher_path.to_string_lossy().into_owned(), + }); + Ok(()) +} + +fn run_full_install( + tx: Sender, + dest: PathBuf, + all_users: bool, + desktop_shortcut: bool, +) -> anyhow::Result<()> { + let _ = tx.send(ProgressEvent::Status { + message: i18n::READING_PACKAGE.into(), + }); + let exe = std::env::current_exe()?; + let bundle = read_bundle_from_exe(&exe, SETUP_MAGIC)?; + let version = bundle.meta.version.clone(); + let edition = FromChatEdition::from_registry_key(&bundle.meta.registration_id) + .unwrap_or(FromChatEdition::Release); + + let _ = tx.send(ProgressEvent::Status { + message: i18n::COPYING.into(), + }); + let _ = tx.send(ProgressEvent::Progress { fraction: 0.2 }); + std::fs::create_dir_all(&dest)?; + extract_zstd_tar(&bundle.payload_zstd, &dest)?; + let _ = tx.send(ProgressEvent::Progress { fraction: 0.75 }); + let app_exe = find_app_exe(&dest)?; + #[cfg(windows)] + { + let _ = tx.send(ProgressEvent::Status { + message: i18n::REGISTERING.into(), + }); + let icon = write_install_icon(&dest)?; + let uninstaller = copy_uninstall_setup(&exe, &dest)?; + fromchat_setup_common::write_uninstall_registry( + all_users, + edition, + &version, + &dest, + &uninstaller, + &icon, + )?; + if desktop_shortcut { + let desk = fromchat_setup_common::desktop_folder()?; + let link = desk.join(format!("{}.lnk", edition.shortcut_name())); + fromchat_setup_common::create_shortcut( + &link, + &app_exe, + &dest, + edition.display_name(), + &icon, + )?; + } + let programs = fromchat_setup_common::programs_folder(all_users)?; + let link = programs.join(format!("{}.lnk", edition.shortcut_name())); + fromchat_setup_common::create_shortcut( + &link, + &app_exe, + &dest, + edition.display_name(), + &icon, + )?; + } + let _ = tx.send(ProgressEvent::Progress { fraction: 1.0 }); + let _ = tx.send(ProgressEvent::Done { + launch_path: app_exe.to_string_lossy().into_owned(), + }); + Ok(()) +} + +#[cfg(windows)] +fn begin_elevated_install( + dest: &std::path::Path, + desktop_shortcut: bool, +) -> anyhow::Result { + let exe = std::env::current_exe()?; + let bundle = read_bundle_from_exe(&exe, SETUP_MAGIC)?; + let edition = FromChatEdition::from_registry_key(&bundle.meta.registration_id) + .unwrap_or(FromChatEdition::Release); + let temp = std::env::temp_dir().join("fromchat-setup-payload"); + let _ = std::fs::remove_dir_all(&temp); + std::fs::create_dir_all(&temp)?; + let payload_path = temp.join("payload.zst"); + std::fs::write(&payload_path, &bundle.payload_zstd)?; + let helper_path = temp.join("fromchat-setup-helper.exe"); + std::fs::write(&helper_path, &bundle.helper)?; + let setup_copy = temp.join("fromchat-setup.exe"); + std::fs::copy(&exe, &setup_copy)?; + let uninstaller = dest.join(UNINSTALL_SETUP_EXE); + let cmd = HelperCommand::Install { + dest: dest.to_string_lossy().into_owned(), + version: bundle.meta.version.clone(), + all_users: true, + edition: edition_tag(edition).into(), + start_menu: true, + desktop: desktop_shortcut, + payload_path: payload_path.to_string_lossy().into_owned(), + uninstaller_path: uninstaller.to_string_lossy().into_owned(), + setup_exe_path: setup_copy.to_string_lossy().into_owned(), + }; + let cmd_json = serde_json::to_string(&cmd)?; + let pipe = fromchat_setup_common::NamedPipeServer::create(fromchat_setup_common::PIPE_NAME)?; + fromchat_setup_common::elevate_helper( + &helper_path, + &format!("--pipe {}", fromchat_setup_common::PIPE_NAME), + )?; + pipe.accept()?; + Ok(ElevatedInstallSession { pipe, cmd_json }) +} + +#[cfg(windows)] +fn begin_elevated_uninstall( + installed: &InstalledFromChat, + preserve_user_data: bool, +) -> anyhow::Result { + let exe = std::env::current_exe()?; + let bundle = read_bundle_from_exe(&exe, SETUP_MAGIC)?; + let temp = std::env::temp_dir().join("fromchat-setup-payload"); + let _ = std::fs::remove_dir_all(&temp); + std::fs::create_dir_all(&temp)?; + let helper_path = temp.join("fromchat-setup-helper.exe"); + std::fs::write(&helper_path, &bundle.helper)?; + let cmd = HelperCommand::Uninstall { + install_dir: installed.install_dir.to_string_lossy().into_owned(), + all_users: installed.all_users, + edition: edition_tag(installed.edition).into(), + preserve_setup_exe: std::env::current_exe() + .ok() + .map(|path| path.to_string_lossy().into_owned()), + preserve_user_data, + }; + let cmd_json = serde_json::to_string(&cmd)?; + let pipe = fromchat_setup_common::NamedPipeServer::create(fromchat_setup_common::PIPE_NAME)?; + fromchat_setup_common::elevate_helper( + &helper_path, + &format!("--pipe {}", fromchat_setup_common::PIPE_NAME), + )?; + pipe.accept()?; + Ok(ElevatedInstallSession { pipe, cmd_json }) +} + +#[cfg(windows)] +fn begin_elevated_upgrade(installed: &InstalledFromChat) -> anyhow::Result { + let exe = std::env::current_exe()?; + let bundle = read_bundle_from_exe(&exe, SETUP_MAGIC)?; + let temp = std::env::temp_dir().join("fromchat-setup-payload"); + let _ = std::fs::remove_dir_all(&temp); + std::fs::create_dir_all(&temp)?; + let payload_path = temp.join("payload.zst"); + std::fs::write(&payload_path, &bundle.payload_zstd)?; + let helper_path = temp.join("fromchat-setup-helper.exe"); + std::fs::write(&helper_path, &bundle.helper)?; + let setup_copy = temp.join("fromchat-setup.exe"); + std::fs::copy(&exe, &setup_copy)?; + let cmd = HelperCommand::Upgrade { + dest: installed.install_dir.to_string_lossy().into_owned(), + version: bundle.meta.version.clone(), + all_users: installed.all_users, + edition: edition_tag(installed.edition).into(), + payload_path: payload_path.to_string_lossy().into_owned(), + setup_exe_path: setup_copy.to_string_lossy().into_owned(), + desktop_shortcut: desktop_shortcut_exists(installed), + }; + let cmd_json = serde_json::to_string(&cmd)?; + let pipe = fromchat_setup_common::NamedPipeServer::create(fromchat_setup_common::PIPE_NAME)?; + fromchat_setup_common::elevate_helper( + &helper_path, + &format!("--pipe {}", fromchat_setup_common::PIPE_NAME), + )?; + pipe.accept()?; + Ok(ElevatedInstallSession { pipe, cmd_json }) +} + +fn run_uninstall( + tx: Sender, + installed: InstalledFromChat, + preserve_user_data: bool, +) -> anyhow::Result<()> { + let _ = tx.send(ProgressEvent::Status { + message: i18n::REMOVING_FILES.into(), + }); + let _ = tx.send(ProgressEvent::Progress { fraction: 0.5 }); + let preserve = std::env::current_exe() + .ok() + .filter(|path| path.starts_with(&installed.install_dir)); + uninstall_fromchat(&installed, preserve.as_deref(), preserve_user_data)?; + let _ = tx.send(ProgressEvent::Progress { fraction: 1.0 }); + let _ = tx.send(ProgressEvent::Uninstalled); + Ok(()) +} + +fn run_upgrade( + tx: Sender, + installed: InstalledFromChat, +) -> anyhow::Result<()> { + let _ = tx.send(ProgressEvent::Status { + message: i18n::READING_PACKAGE.into(), + }); + let exe = std::env::current_exe()?; + let bundle = read_bundle_from_exe(&exe, SETUP_MAGIC)?; + let version = bundle.meta.version.clone(); + let dest = installed.install_dir.clone(); + let desktop_shortcut = desktop_shortcut_exists(&installed); + + let _ = tx.send(ProgressEvent::Status { + message: i18n::UPGRADE_REPLACING_FILES.into(), + }); + let _ = tx.send(ProgressEvent::Progress { fraction: 0.2 }); + wipe_install_dir(&dest, None)?; + std::fs::create_dir_all(&dest)?; + extract_zstd_tar(&bundle.payload_zstd, &dest)?; + let _ = tx.send(ProgressEvent::Progress { fraction: 0.75 }); + let app_exe = find_app_exe(&dest)?; + #[cfg(windows)] + { + let _ = tx.send(ProgressEvent::Status { + message: i18n::REGISTERING.into(), + }); + let icon = write_install_icon(&dest)?; + let uninstaller = copy_uninstall_setup(&exe, &dest)?; + fromchat_setup_common::write_uninstall_registry( + installed.all_users, + installed.edition, + &version, + &dest, + &uninstaller, + &icon, + )?; + let shortcut = format!("{}.lnk", installed.edition.shortcut_name()); + let programs = fromchat_setup_common::programs_folder(installed.all_users)?; + fromchat_setup_common::create_shortcut( + &programs.join(&shortcut), + &app_exe, + &dest, + installed.edition.display_name(), + &icon, + )?; + if desktop_shortcut { + let desk = fromchat_setup_common::desktop_folder()?; + fromchat_setup_common::create_shortcut( + &desk.join(&shortcut), + &app_exe, + &dest, + installed.edition.display_name(), + &icon, + )?; + } + } + let _ = tx.send(ProgressEvent::Progress { fraction: 1.0 }); + let _ = tx.send(ProgressEvent::Done { + launch_path: app_exe.to_string_lossy().into_owned(), + }); + Ok(()) +} + +fn edition_tag(edition: FromChatEdition) -> &'static str { + match edition { + FromChatEdition::Release => "release", + FromChatEdition::Beta => "beta", + } +} + +fn desktop_shortcut_exists(installed: &InstalledFromChat) -> bool { + #[cfg(windows)] + { + fromchat_setup_common::desktop_folder() + .ok() + .is_some_and(|desk| { + desk.join(format!("{}.lnk", installed.edition.shortcut_name())) + .is_file() + }) + } + #[cfg(not(windows))] + { + let _ = installed; + false + } +} + +#[cfg(windows)] +fn run_elevated_install( + session: ElevatedInstallSession, + tx: Sender, +) -> anyhow::Result<()> { + fromchat_setup_common::pipe_write_line(session.pipe.handle(), &session.cmd_json)?; + let mut got_terminal = false; + loop { + let resp = match fromchat_setup_common::pipe_read_line(session.pipe.handle()) { + Ok(resp) => resp, + Err(_error) if got_terminal => return Ok(()), + Err(error) => return Err(error.into()), + }; + if resp.is_empty() { + break; + } + if let Ok(ev) = serde_json::from_str::(&resp) { + let done = matches!( + ev, + ProgressEvent::Done { .. } + | ProgressEvent::Uninstalled + | ProgressEvent::Error { .. } + ); + let _ = tx.send(ev); + if done { + got_terminal = true; + break; + } + } + } + Ok(()) +} + +fn find_app_exe(dest: &PathBuf) -> anyhow::Result { + let candidates = [ + dest.join("FromChat.exe"), + dest.join("bin").join("FromChat.exe"), + ]; + for c in candidates { + if c.is_file() { + return Ok(c); + } + } + for entry in walkdir_files(dest)? { + if entry + .file_name() + .is_some_and(|n| n.eq_ignore_ascii_case("FromChat.exe")) + { + return Ok(entry); + } + } + anyhow::bail!("FromChat.exe not found after extract"); +} + +fn walkdir_files(root: &PathBuf) -> anyhow::Result> { + let mut out = Vec::new(); + fn rec(dir: &PathBuf, out: &mut Vec) -> anyhow::Result<()> { + for e in std::fs::read_dir(dir)? { + let e = e?; + let p = e.path(); + if e.file_type()?.is_dir() { + rec(&p, out)?; + } else { + out.push(p); + } + } + Ok(()) + } + rec(root, &mut out)?; + Ok(out) +} + +fn default_install_path(all_users: bool) -> String { + #[cfg(windows)] + { + fromchat_setup_common::default_install_dir(all_users) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| { + if all_users { + r"C:\Program Files\FromChat".into() + } else { + format!( + r"{}\Programs\FromChat", + std::env::var("LOCALAPPDATA").unwrap_or_else(|_| r"C:\Users\Public".into()) + ) + } + }) + } + #[cfg(not(windows))] + { + let _ = all_users; + "/tmp/FromChat".into() + } +} + +fn draw_welcome_brand_overlay(painter: &egui::Painter, full: Rect, logo: &TextureHandle) { + // Match Android `WelcomeScreen` logo at 112dp; sit under the 40px title bar. + let cx = full.center().x; + let logo_size = theme::EXPRESSIVE_HERO_SIZE; + let logo_y = full.top() + TITLE_BAR_HEIGHT + 28.0 + logo_size * 0.5; + let logo_rect = Rect::from_center_size(Pos2::new(cx, logo_y), Vec2::splat(logo_size)); + painter.image( + logo.id(), + logo_rect, + Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), + Color32::WHITE, + ); + theme::draw_brand_wordmark( + painter, + Pos2::new(cx, logo_y + logo_size * 0.5 + 28.0), + "FromChat", + wordmark_brand(), + ); + let tagline = painter.layout( + i18n::TAGLINE.to_owned(), + body_medium(13.0), + ON_SURFACE_VARIANT, + (full.width() - 48.0).max(120.0), + ); + painter.galley( + Pos2::new( + cx - tagline.size().x * 0.5, + logo_y + logo_size * 0.5 + 52.0, + ), + tagline, + Color32::from_rgba_unmultiplied(230, 224, 233, 230), + ); +} + +fn draw_title_bar( + ui: &mut Ui, + window: Rect, + over_brand: bool, + logo: &TextureHandle, + window_focused: bool, + window_title: &str, + on_close: &mut dyn FnMut(), + on_minimize: &mut dyn FnMut(), +) { + const TITLE_BUTTON_WIDTH: f32 = 46.0; + + let rect = Rect::from_min_size( + window.left_top(), + Vec2::new(window.width(), TITLE_BAR_HEIGHT), + ); + let response = ui.interact(rect, ui.id().with("title_bar"), Sense::click_and_drag()); + let title_color = if window_focused { + ON_SURFACE_VARIANT + } else { + TITLE_BAR_INACTIVE + }; + + if !over_brand { + ui.painter().rect_filled( + rect, + Rounding { + nw: theme::WINDOW_CORNER_RADIUS, + ne: theme::WINDOW_CORNER_RADIUS, + sw: 0.0, + se: 0.0, + }, + SURFACE, + ); + } + + let icon_size = 16.0; + let icon_rect = Rect::from_center_size( + Pos2::new(rect.left() + 16.0 + icon_size * 0.5, rect.center().y), + Vec2::splat(icon_size), + ); + ui.painter().image( + logo.id(), + icon_rect, + Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), + if window_focused { + Color32::WHITE + } else { + Color32::from_rgba_unmultiplied(255, 255, 255, 160) + }, + ); + ui.painter().text( + Pos2::new(icon_rect.right() + 8.0, rect.center().y), + egui::Align2::LEFT_CENTER, + window_title, + body_medium(14.0), + title_color, + ); + + let close_rect = Rect::from_min_size( + Pos2::new(rect.right() - TITLE_BUTTON_WIDTH, rect.top()), + Vec2::new(TITLE_BUTTON_WIDTH, rect.height()), + ); + let minimize_rect = Rect::from_min_size( + Pos2::new(close_rect.left() - TITLE_BUTTON_WIDTH, rect.top()), + Vec2::new(TITLE_BUTTON_WIDTH, rect.height()), + ); + + if title_bar_button(ui, minimize_rect, MINIMIZE, window_focused, "minimize", false) { + on_minimize(); + } + if title_bar_button(ui, close_rect, CLOSE, window_focused, "close", true) { + on_close(); + } + + if response.dragged() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } +} + +fn title_bar_button( + ui: &mut Ui, + rect: Rect, + icon: &str, + window_focused: bool, + id_suffix: &str, + is_close: bool, +) -> bool { + let response = ui.interact(rect, ui.id().with(id_suffix), Sense::click()); + let hovered = response.hovered(); + let pressed = response.is_pointer_button_down_on(); + let (hover_t, press_t) = widgets::title_bar_interaction_anim(ui, response.id, hovered, pressed); + if is_close { + theme::draw_title_bar_close_state(ui.painter(), rect, hover_t, press_t); + } else { + theme::draw_surface_state_layer_rect(ui.painter(), rect, hover_t, press_t); + } + let icon_color = if is_close && (hovered || pressed) { + ON_ERROR_FILLED + } else if hovered { + ON_SURFACE + } else if window_focused { + ON_SURFACE_VARIANT + } else { + TITLE_BAR_INACTIVE + }; + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + icon, + icons::font(16.0), + icon_color, + ); + response.clicked() +} + +fn draw_logo(ui: &mut Ui, logo: &TextureHandle, size: f32) { + let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + let logo_rect = Rect::from_center_size(rect.center(), Vec2::splat(size)); + ui.painter().image( + logo.id(), + logo_rect, + Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), + Color32::WHITE, + ); +} diff --git a/app/desktop/windows-setup/setup/src/widgets.rs b/app/desktop/windows-setup/setup/src/widgets.rs new file mode 100644 index 0000000..9d352b4 --- /dev/null +++ b/app/desktop/windows-setup/setup/src/widgets.rs @@ -0,0 +1,1043 @@ +use crate::anim::animate_towards; +use crate::fonts::{body, body_medium, label_small, title_large, body_large, label_large}; +use crate::icons::{self, ARROW_BACK, ARROW_FORWARD, CHECK, CLOSE, DELETE, DOWNLOAD, FOLDER_OPEN, ROCKET_LAUNCH, UNARCHIVE}; +use crate::theme::{ + self, CHECKBOX_CORNER_RADIUS, CHECKBOX_LABEL_GAP, CHECKBOX_MIN_TOUCH_HEIGHT, CHECKBOX_SIZE, + CHECKBOX_STATE_LAYER_SIZE, CHECKBOX_STROKE, CHOICE_CARD_ICON_GAP, CHOICE_CARD_PAD, ERROR, + EXPRESSIVE_HERO_ICON_SIZE, EXPRESSIVE_HERO_SIZE, EXPRESSIVE_HERO_TITLE_GAP, FIELD_LABEL_GAP, + FILLED_BUTTON_HEIGHT, FILLED_BUTTON_ICON_GAP, FILLED_BUTTON_ICON_SIZE, FILLED_BUTTON_MIN_WIDTH, + FILLED_BUTTON_PAD_END_WITH_ICON, FILLED_BUTTON_PAD_END_WITH_TRAILING_ICON, + FILLED_BUTTON_PAD_HORIZONTAL, FILLED_BUTTON_PAD_START_WITH_ICON, ICON_BUTTON_ICON_SIZE, + ICON_BUTTON_SIZE, LIST_ICON_CONTAINER_SIZE, LIST_ICON_SIZE, + NAV_BACK_BUTTON_SIZE, NAV_BACK_STATE_LAYER_SIZE, ON_ERROR_FILLED, ON_PRIMARY, ON_PRIMARY_CONTAINER, + ON_SURFACE, ON_SURFACE_VARIANT, OUTLINE, PRIMARY, SURFACE_CONTAINER, SURFACE_CONTAINER_HIGH, + TEXT_BUTTON_HEIGHT, TEXT_BUTTON_ICON_GAP, TEXT_BUTTON_ICON_SIZE, TEXT_BUTTON_MIN_WIDTH, + TEXT_BUTTON_PAD_END_WITH_ICON, TEXT_BUTTON_PAD_START_WITH_ICON, TEXT_FIELD_CORNER_RADIUS, + TEXT_FIELD_HEIGHT, TEXT_FIELD_PAD_HORIZONTAL, TEXT_FIELD_TRAILING_ICON_WIDTH, +}; +use eframe::egui::{self, Color32, Id, Pos2, Rect, Rounding, Sense, Stroke, TextureHandle, Ui, Vec2}; + +pub const H_PADDING: f32 = 24.0; +/// Matches [H_PADDING] so bottom / side insets stay equal. +pub const BOTTOM_BAR_PAD: f32 = H_PADDING; +pub const BUTTON_HEIGHT: f32 = FILLED_BUTTON_HEIGHT; +pub const BOTTOM_BAR_CONTROL_HEIGHT: f32 = FILLED_BUTTON_HEIGHT.max(NAV_BACK_BUTTON_SIZE); +pub const BOTTOM_BAR_HEIGHT: f32 = BOTTOM_BAR_PAD + BOTTOM_BAR_CONTROL_HEIGHT + BOTTOM_BAR_PAD; + +fn interaction_anim(ui: &Ui, id: Id, hovered: bool, pressed: bool) -> (f32, f32) { + let dt = ui.input(|i| i.stable_dt); + let hover_id = id.with("hover"); + let press_id = id.with("press"); + + let (hover_t, press_t) = ui.ctx().data_mut(|d| { + let hover_t = *d.get_temp_mut_or_insert_with(hover_id, || 0.0); + let press_t = *d.get_temp_mut_or_insert_with(press_id, || 0.0); + (hover_t, press_t) + }); + + let new_hover = animate_towards(hover_t, if hovered { 1.0 } else { 0.0 }, dt, 14.0); + let new_press = animate_towards(press_t, if pressed { 1.0 } else { 0.0 }, dt, 22.0); + + if (new_hover - hover_t).abs() > 0.001 || (new_press - press_t).abs() > 0.001 { + ui.ctx().data_mut(|d| { + *d.get_temp_mut_or_insert_with(hover_id, || 0.0) = new_hover; + *d.get_temp_mut_or_insert_with(press_id, || 0.0) = new_press; + }); + ui.ctx().request_repaint(); + } + + (new_hover, new_press) +} + +pub fn title_bar_interaction_anim(ui: &Ui, id: Id, hovered: bool, pressed: bool) -> (f32, f32) { + interaction_anim(ui, id, hovered, pressed) +} + +fn draw_choice_card_icon( + ui: &mut Ui, + icon_rect: Rect, + icon_texture: Option<&TextureHandle>, + fallback_icon: &str, +) { + if let Some(texture) = icon_texture { + let inset = icon_rect.width() * 0.1; + let image_rect = icon_rect.shrink(inset); + ui.painter().image( + texture.id(), + image_rect, + Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), + Color32::WHITE, + ); + } else { + theme::draw_tonal_icon_container(ui.painter(), icon_rect); + ui.painter().text( + icon_rect.center(), + egui::Align2::CENTER_CENTER, + fallback_icon, + icons::font(LIST_ICON_SIZE), + PRIMARY, + ); + } +} + +#[derive(Clone, Copy)] +pub enum PrimaryAction { + Install, + Extract, + Uninstall, + Upgrade, + Next, + Close, +} + +pub enum ExpressiveHeroShape { + Cookie4Sided, + Cookie6Sided, + Circle, +} + +#[derive(Clone, Copy)] +enum FilledButtonIcon { + Leading(&'static str), + Trailing(&'static str), +} + +pub fn bottom_bar( + ui: &mut Ui, + hover_enabled: bool, + show_back: bool, + on_back: impl FnOnce(), + on_next: impl FnOnce(), + primary: PrimaryAction, +) { + let back_clicked = show_back && nav_back_button(ui, hover_enabled); + let mut next_clicked = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.set_width(ui.available_width()); + next_clicked = match primary { + PrimaryAction::Install => filled_install_button(ui, hover_enabled), + PrimaryAction::Extract => filled_extract_button(ui, hover_enabled), + PrimaryAction::Uninstall => filled_uninstall_button(ui, hover_enabled), + PrimaryAction::Upgrade => filled_upgrade_button(ui, hover_enabled), + PrimaryAction::Next => filled_next_button(ui, hover_enabled), + PrimaryAction::Close => filled_button_label(ui, crate::i18n::CLOSE, hover_enabled), + }; + }); + if back_clicked { + on_back(); + } + if next_clicked { + on_next(); + } +} + +pub fn filled_button_label(ui: &mut Ui, label: &str, hover_enabled: bool) -> bool { + filled_button(ui, None, label, hover_enabled) +} + +pub fn filled_install_button(ui: &mut Ui, hover_enabled: bool) -> bool { + filled_button( + ui, + Some(FilledButtonIcon::Leading(DOWNLOAD)), + crate::i18n::INSTALL_ACTION, + hover_enabled, + ) +} + +pub fn filled_extract_button(ui: &mut Ui, hover_enabled: bool) -> bool { + filled_button( + ui, + Some(FilledButtonIcon::Leading(UNARCHIVE)), + crate::i18n::EXTRACT_ACTION, + hover_enabled, + ) +} + +pub fn filled_uninstall_button(ui: &mut Ui, hover_enabled: bool) -> bool { + filled_error_button( + ui, + Some(FilledButtonIcon::Leading(DELETE)), + crate::i18n::UNINSTALL_ACTION, + hover_enabled, + ) +} + +pub fn filled_upgrade_button(ui: &mut Ui, hover_enabled: bool) -> bool { + filled_button( + ui, + Some(FilledButtonIcon::Leading(DOWNLOAD)), + crate::i18n::UPGRADE_ACTION, + hover_enabled, + ) +} + +pub fn filled_next_button(ui: &mut Ui, hover_enabled: bool) -> bool { + filled_button( + ui, + Some(FilledButtonIcon::Trailing(ARROW_FORWARD)), + crate::i18n::NEXT, + hover_enabled, + ) +} + +/// M3 filled error button (`ButtonDefaults`: 40dp height, labelLarge, optional 18dp icon). +fn filled_error_button( + ui: &mut Ui, + icon: Option, + label: &str, + hover_enabled: bool, +) -> bool { + let label_galley = ui.painter().layout( + label.to_owned(), + label_large(), + ON_ERROR_FILLED, + f32::INFINITY, + ); + + let label_width = label_galley.size().x; + let width = match icon { + Some(FilledButtonIcon::Leading(_)) => (FILLED_BUTTON_PAD_START_WITH_ICON + + FILLED_BUTTON_ICON_SIZE + + FILLED_BUTTON_ICON_GAP + + label_galley.size().x + + FILLED_BUTTON_PAD_END_WITH_ICON) + .max(FILLED_BUTTON_MIN_WIDTH), + Some(FilledButtonIcon::Trailing(_)) => (FILLED_BUTTON_PAD_HORIZONTAL + + label_width + + FILLED_BUTTON_ICON_GAP + + FILLED_BUTTON_ICON_SIZE + + FILLED_BUTTON_PAD_END_WITH_TRAILING_ICON) + .max(FILLED_BUTTON_MIN_WIDTH), + None => (FILLED_BUTTON_PAD_HORIZONTAL * 2.0 + label_width).max(FILLED_BUTTON_MIN_WIDTH), + }; + + let desired = Vec2::new(width, FILLED_BUTTON_HEIGHT); + let (rect, response) = ui.allocate_exact_size(desired, Sense::click()); + let hovered = hover_enabled && response.hovered(); + let pressed = hover_enabled && response.is_pointer_button_down_on(); + let (hover_t, press_t) = interaction_anim(ui, response.id, hovered, pressed); + theme::draw_filled_error_button(ui.painter(), rect, hover_t, press_t); + + let cy = rect.center().y; + match icon { + Some(FilledButtonIcon::Leading(icon)) => { + let mut x = rect.left() + FILLED_BUTTON_PAD_START_WITH_ICON; + ui.painter().text( + Pos2::new(x + FILLED_BUTTON_ICON_SIZE * 0.5, cy), + egui::Align2::CENTER_CENTER, + icon, + icons::font(FILLED_BUTTON_ICON_SIZE), + ON_ERROR_FILLED, + ); + x += FILLED_BUTTON_ICON_SIZE + FILLED_BUTTON_ICON_GAP; + ui.painter().galley( + Pos2::new(x, cy - label_galley.size().y * 0.5), + label_galley, + ON_ERROR_FILLED, + ); + } + Some(FilledButtonIcon::Trailing(icon)) => { + let mut x = rect.left() + FILLED_BUTTON_PAD_HORIZONTAL; + ui.painter().galley( + Pos2::new(x, cy - label_galley.size().y * 0.5), + label_galley, + ON_ERROR_FILLED, + ); + x += label_width + FILLED_BUTTON_ICON_GAP; + ui.painter().text( + Pos2::new(x + FILLED_BUTTON_ICON_SIZE * 0.5, cy), + egui::Align2::CENTER_CENTER, + icon, + icons::font(FILLED_BUTTON_ICON_SIZE), + ON_ERROR_FILLED, + ); + } + None => { + ui.painter().galley( + Pos2::new( + rect.center().x - label_galley.size().x * 0.5, + cy - label_galley.size().y * 0.5, + ), + label_galley, + ON_ERROR_FILLED, + ); + } + } + + response.clicked() +} + +/// M3 filled button (`ButtonDefaults`: 40dp height, labelLarge, optional 18dp icon). +fn filled_button( + ui: &mut Ui, + icon: Option, + label: &str, + hover_enabled: bool, +) -> bool { + let label_galley = ui.painter().layout( + label.to_owned(), + label_large(), + ON_PRIMARY, + f32::INFINITY, + ); + + let label_width = label_galley.size().x; + let width = match icon { + Some(FilledButtonIcon::Leading(_)) => (FILLED_BUTTON_PAD_START_WITH_ICON + + FILLED_BUTTON_ICON_SIZE + + FILLED_BUTTON_ICON_GAP + + label_galley.size().x + + FILLED_BUTTON_PAD_END_WITH_ICON) + .max(FILLED_BUTTON_MIN_WIDTH), + Some(FilledButtonIcon::Trailing(_)) => (FILLED_BUTTON_PAD_HORIZONTAL + + label_width + + FILLED_BUTTON_ICON_GAP + + FILLED_BUTTON_ICON_SIZE + + FILLED_BUTTON_PAD_END_WITH_TRAILING_ICON) + .max(FILLED_BUTTON_MIN_WIDTH), + None => (FILLED_BUTTON_PAD_HORIZONTAL * 2.0 + label_width).max(FILLED_BUTTON_MIN_WIDTH), + }; + + let desired = Vec2::new(width, FILLED_BUTTON_HEIGHT); + let (rect, response) = ui.allocate_exact_size(desired, Sense::click()); + let hovered = hover_enabled && response.hovered(); + let pressed = hover_enabled && response.is_pointer_button_down_on(); + let (hover_t, press_t) = interaction_anim(ui, response.id, hovered, pressed); + theme::draw_filled_button(ui.painter(), rect, hover_t, press_t); + + let cy = rect.center().y; + match icon { + Some(FilledButtonIcon::Leading(icon)) => { + let mut x = rect.left() + FILLED_BUTTON_PAD_START_WITH_ICON; + ui.painter().text( + Pos2::new(x + FILLED_BUTTON_ICON_SIZE * 0.5, cy), + egui::Align2::CENTER_CENTER, + icon, + icons::font(FILLED_BUTTON_ICON_SIZE), + ON_PRIMARY, + ); + x += FILLED_BUTTON_ICON_SIZE + FILLED_BUTTON_ICON_GAP; + ui.painter().galley( + Pos2::new(x, cy - label_galley.size().y * 0.5), + label_galley, + ON_PRIMARY, + ); + } + Some(FilledButtonIcon::Trailing(icon)) => { + let mut x = rect.left() + FILLED_BUTTON_PAD_HORIZONTAL; + ui.painter().galley( + Pos2::new(x, cy - label_galley.size().y * 0.5), + label_galley, + ON_PRIMARY, + ); + x += label_width + FILLED_BUTTON_ICON_GAP; + ui.painter().text( + Pos2::new(x + FILLED_BUTTON_ICON_SIZE * 0.5, cy), + egui::Align2::CENTER_CENTER, + icon, + icons::font(FILLED_BUTTON_ICON_SIZE), + ON_PRIMARY, + ); + } + None => { + ui.painter().galley( + Pos2::new( + rect.center().x - label_galley.size().x * 0.5, + cy - label_galley.size().y * 0.5, + ), + label_galley, + ON_PRIMARY, + ); + } + } + + response.clicked() +} + +pub fn filled_launch_button(ui: &mut Ui, hover_enabled: bool) -> bool { + filled_button( + ui, + Some(FilledButtonIcon::Leading(ROCKET_LAUNCH)), + crate::i18n::LAUNCH_ACTION, + hover_enabled, + ) +} + +pub fn done_bottom_bar(ui: &mut Ui, hover_enabled: bool) -> (bool, bool) { + let mut launch = false; + let mut close = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.set_width(ui.available_width()); + launch = filled_launch_button(ui, hover_enabled); + ui.add_space(8.0); + close = text_button_icon(ui, CLOSE, crate::i18n::CLOSE, hover_enabled); + }); + (launch, close) +} + +/// Compact back control for bottom bars (40dp hit target, 24dp icon). +pub fn nav_back_button(ui: &mut Ui, hover_enabled: bool) -> bool { + let size = NAV_BACK_BUTTON_SIZE; + let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); + let hovered = hover_enabled && response.hovered(); + let pressed = hover_enabled && response.is_pointer_button_down_on(); + let (hover_t, press_t) = interaction_anim(ui, response.id, hovered, pressed); + theme::draw_surface_state_layer( + ui.painter(), + rect.center(), + NAV_BACK_STATE_LAYER_SIZE * 0.5, + hover_t, + press_t, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + ARROW_BACK, + icons::font(ICON_BUTTON_ICON_SIZE), + ON_SURFACE, + ); + response.clicked() +} + +/// M3 text button with leading icon (`ButtonDefaults` text button + icon). +pub fn text_button_icon(ui: &mut Ui, icon: &str, label: &str, hover_enabled: bool) -> bool { + let label_galley = ui.painter().layout( + label.to_owned(), + label_large(), + PRIMARY, + f32::INFINITY, + ); + let width = (TEXT_BUTTON_PAD_START_WITH_ICON + + TEXT_BUTTON_ICON_SIZE + + TEXT_BUTTON_ICON_GAP + + label_galley.size().x + + TEXT_BUTTON_PAD_END_WITH_ICON) + .max(TEXT_BUTTON_MIN_WIDTH); + let (rect, response) = + ui.allocate_exact_size(Vec2::new(width, TEXT_BUTTON_HEIGHT), Sense::click()); + let hovered = hover_enabled && response.hovered(); + let pressed = hover_enabled && response.is_pointer_button_down_on(); + let (hover_t, press_t) = interaction_anim(ui, response.id, hovered, pressed); + theme::draw_text_button_state(ui.painter(), rect, hover_t, press_t); + + let cy = rect.center().y; + let mut x = rect.left() + TEXT_BUTTON_PAD_START_WITH_ICON; + ui.painter().text( + Pos2::new(x + TEXT_BUTTON_ICON_SIZE * 0.5, cy), + egui::Align2::CENTER_CENTER, + icon, + icons::font(TEXT_BUTTON_ICON_SIZE), + PRIMARY, + ); + x += TEXT_BUTTON_ICON_SIZE + TEXT_BUTTON_ICON_GAP; + ui.painter().galley( + Pos2::new(x, cy - label_galley.size().y * 0.5), + label_galley, + PRIMARY, + ); + response.clicked() +} + +pub fn icon_button(ui: &mut Ui, icon: &str, hover_enabled: bool, icon_color: Color32) -> bool { + let size = ICON_BUTTON_SIZE; + let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); + let hovered = hover_enabled && response.hovered(); + let pressed = hover_enabled && response.is_pointer_button_down_on(); + let (hover_t, press_t) = interaction_anim(ui, response.id, hovered, pressed); + theme::draw_surface_state_layer( + ui.painter(), + rect.center(), + CHECKBOX_STATE_LAYER_SIZE * 0.5, + hover_t, + press_t, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + icon, + icons::font(ICON_BUTTON_ICON_SIZE), + icon_color, + ); + response.clicked() +} + +pub fn text_button_label(ui: &mut Ui, label: &str, hover_enabled: bool) -> bool { + let label_galley = ui.painter().layout( + label.to_owned(), + label_large(), + PRIMARY, + f32::INFINITY, + ); + let width = (label_galley.size().x + 24.0).max(48.0); + let (rect, response) = ui.allocate_exact_size(Vec2::new(width, 48.0), Sense::click()); + let hovered = hover_enabled && response.hovered(); + let pressed = hover_enabled && response.is_pointer_button_down_on(); + let (hover_t, press_t) = interaction_anim(ui, response.id, hovered, pressed); + theme::draw_text_button_state(ui.painter(), rect.expand(4.0), hover_t, press_t); + ui.painter().galley( + Pos2::new( + rect.center().x - label_galley.size().x * 0.5, + rect.center().y - label_galley.size().y * 0.5, + ), + label_galley, + PRIMARY, + ); + response.clicked() +} + +/// Checkbox: M3 18dp box, 40dp state layer on box, animated check fade. +pub fn checkbox(ui: &mut Ui, checked: &mut bool, label: &str, hover_enabled: bool) -> bool { + let wrap_w = (ui.available_width() - CHECKBOX_SIZE - CHECKBOX_LABEL_GAP).max(80.0); + let label_galley = ui.painter().layout( + label.to_owned(), + body_large(), + ON_SURFACE, + wrap_w, + ); + let row_h = label_galley + .size() + .y + .max(CHECKBOX_SIZE) + .max(CHECKBOX_MIN_TOUCH_HEIGHT); + let (rect, response) = + ui.allocate_exact_size(Vec2::new(ui.available_width(), row_h), Sense::click()); + let box_hovered = hover_enabled && { + let box_top = rect.top() + (row_h - CHECKBOX_SIZE) * 0.5; + let box_rect = Rect::from_min_size( + Pos2::new(rect.left() + 2.0, box_top), + Vec2::splat(CHECKBOX_SIZE), + ); + let layer = Rect::from_center_size( + box_rect.center(), + Vec2::splat(CHECKBOX_STATE_LAYER_SIZE), + ); + ui.ctx().input(|i| { + i.pointer + .hover_pos() + .is_some_and(|p| layer.contains(p) || box_rect.contains(p)) + }) || response.hovered() + }; + let pressed = hover_enabled && response.is_pointer_button_down_on(); + let (hover_t, press_t) = interaction_anim(ui, response.id, box_hovered, pressed); + + if response.clicked() { + *checked = !*checked; + } + + let dt = ui.input(|i| i.stable_dt); + let checked_id = response.id.with("checked_t"); + let checked_t = { + let (t, changed) = ui.ctx().data_mut(|d| { + let t = *d.get_temp_mut_or_insert_with(checked_id, || { + if *checked { 1.0 } else { 0.0 } + }); + let target = if *checked { 1.0 } else { 0.0 }; + let next = animate_towards(t, target, dt, 18.0); + let changed = (next - t).abs() > 0.001; + if changed { + *d.get_temp_mut_or_insert_with(checked_id, || t) = next; + } + (next, changed) + }); + if changed { + ui.ctx().request_repaint(); + } + t + }; + + let box_top = rect.top() + (row_h - CHECKBOX_SIZE) * 0.5; + let label_top = rect.top() + (row_h - label_galley.size().y) * 0.5; + let box_rect = Rect::from_min_size( + Pos2::new(rect.left() + 2.0, box_top), + Vec2::splat(CHECKBOX_SIZE), + ); + + theme::draw_surface_state_layer( + ui.painter(), + box_rect.center(), + CHECKBOX_STATE_LAYER_SIZE * 0.5, + hover_t, + press_t, + ); + + if checked_t > 0.001 { + let fill = theme::lerp_color( + Color32::TRANSPARENT, + PRIMARY, + checked_t, + ); + ui.painter() + .rect_filled(box_rect, Rounding::same(CHECKBOX_CORNER_RADIUS), fill); + ui.painter().text( + box_rect.center(), + egui::Align2::CENTER_CENTER, + CHECK, + icons::font(14.0), + Color32::from_rgba_unmultiplied( + ON_PRIMARY.r(), + ON_PRIMARY.g(), + ON_PRIMARY.b(), + (checked_t * 255.0) as u8, + ), + ); + } + if checked_t < 0.999 { + let stroke_alpha = ((1.0 - checked_t) * 255.0) as u8; + ui.painter().rect_stroke( + box_rect, + Rounding::same(CHECKBOX_CORNER_RADIUS), + egui::Stroke::new( + CHECKBOX_STROKE, + Color32::from_rgba_unmultiplied(OUTLINE.r(), OUTLINE.g(), OUTLINE.b(), stroke_alpha), + ), + ); + } + + ui.painter().galley( + Pos2::new(box_rect.right() + CHECKBOX_LABEL_GAP, label_top), + label_galley, + ON_SURFACE, + ); + response.clicked() +} + +/// Rounded surface-container path field with trailing folder icon. +pub fn path_field(ui: &mut Ui, value: &mut String, enabled: bool, field_id: Id) -> bool { + let w = ui.available_width(); + let h = TEXT_FIELD_HEIGHT; + let (row, _row_resp) = ui.allocate_exact_size(Vec2::new(w, h), Sense::hover()); + let rounding = Rounding::same(TEXT_FIELD_CORNER_RADIUS); + + ui.painter() + .rect_filled(row, rounding, SURFACE_CONTAINER); + + let icon_w = TEXT_FIELD_TRAILING_ICON_WIDTH; + let pad_x = TEXT_FIELD_PAD_HORIZONTAL; + let text_rect = Rect::from_min_max( + row.left_top() + Vec2::new(pad_x, 0.0), + Pos2::new(row.right() - icon_w, row.bottom()), + ); + let icon_rect = Rect::from_min_max( + Pos2::new(row.right() - icon_w, row.top()), + row.right_bottom(), + ); + + let edit_id = field_id.with("edit"); + let dt = ui.input(|i| i.stable_dt); + let focused = ui.ctx().memory(|m| m.has_focus(edit_id)); + + let (from_text, to_text, fade, animating) = ui.ctx().data_mut(|d| { + let shown_id = field_id.with("shown"); + let from_id = field_id.with("from"); + let to_id = field_id.with("to"); + let fade_id = field_id.with("fade"); + + let mut shown = d + .get_temp::(shown_id) + .unwrap_or_else(|| value.clone()); + let mut fade = *d.get_temp_mut_or_insert_with(fade_id, || 1.0); + let mut from_text = d + .get_temp::(from_id) + .unwrap_or_else(|| shown.clone()); + let mut to_text = d + .get_temp::(to_id) + .unwrap_or_else(|| value.clone()); + + if focused { + shown = value.clone(); + fade = 1.0; + d.insert_temp(shown_id, shown); + d.insert_temp(fade_id, fade); + } else if *value != shown { + if fade >= 0.99 { + from_text = shown.clone(); + to_text = value.clone(); + fade = 0.0; + d.insert_temp(from_id, from_text.clone()); + d.insert_temp(to_id, to_text.clone()); + } + fade = (fade + dt * 8.0).min(1.0); + if fade >= 0.99 { + shown = value.clone(); + d.insert_temp(shown_id, shown); + } + *d.get_temp_mut_or_insert_with(fade_id, || fade) = fade; + } + + let animating = !focused && fade < 0.99 && from_text != to_text; + (from_text, to_text, fade, animating) + }); + if animating { + ui.ctx().request_repaint(); + } + + let paint_path_line = |painter: &egui::Painter, text: &str, alpha: f32| { + if alpha <= 0.01 || text.is_empty() { + return; + } + let galley = painter.layout( + text.to_owned(), + body_large(), + Color32::from_rgba_unmultiplied( + ON_SURFACE.r(), + ON_SURFACE.g(), + ON_SURFACE.b(), + (alpha * 255.0) as u8, + ), + f32::INFINITY, + ); + let mut x = text_rect.left(); + if galley.size().x > text_rect.width() { + x = text_rect.right() - galley.size().x; + } + let y = text_rect.center().y - galley.size().y * 0.5; + painter + .with_clip_rect(text_rect) + .galley(Pos2::new(x, y), galley, Color32::WHITE); + }; + + if animating { + paint_path_line(ui.painter(), &from_text, 1.0 - fade); + paint_path_line(ui.painter(), &to_text, fade); + } + + ui.allocate_ui_at_rect(text_rect, |ui| { + ui.set_clip_rect(text_rect); + ui.with_layout( + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.set_height(text_rect.height()); + ui.set_width(text_rect.width()); + let edit = egui::TextEdit::singleline(value) + .id(edit_id) + .frame(false) + .margin(egui::Margin::symmetric(0.0, 0.0)) + .clip_text(true) + .desired_width(f32::INFINITY) + .text_color(if animating { + Color32::TRANSPARENT + } else { + ON_SURFACE + }) + .font(body_large()); + ui.add_enabled(enabled, edit); + }, + ); + }); + + let mut browse = false; + ui.allocate_ui_at_rect(icon_rect, |ui| { + ui.with_layout( + egui::Layout::centered_and_justified(egui::Direction::LeftToRight), + |ui| { + browse = icon_button(ui, FOLDER_OPEN, enabled, ON_SURFACE); + }, + ); + }); + browse +} + +/// Centered expressive header with Material expressive polygon + title. +pub fn expressive_section_header( + ui: &mut Ui, + title: &str, + icon: &str, + shape: ExpressiveHeroShape, +) { + ui.add_space(4.0); + ui.vertical_centered(|ui| { + let hero = EXPRESSIVE_HERO_SIZE; + let (rect, _) = ui.allocate_exact_size(Vec2::splat(hero), Sense::hover()); + match shape { + ExpressiveHeroShape::Cookie4Sided => { + theme::draw_expressive_cookie_hero(ui.painter(), rect); + } + ExpressiveHeroShape::Cookie6Sided => { + theme::draw_expressive_cookie6_hero(ui.painter(), rect); + } + ExpressiveHeroShape::Circle => { + theme::draw_expressive_circle_hero(ui.painter(), rect); + } + } + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + icon, + icons::font(EXPRESSIVE_HERO_ICON_SIZE), + ON_PRIMARY_CONTAINER, + ); + ui.add_space(EXPRESSIVE_HERO_TITLE_GAP); + ui.label( + egui::RichText::new(title) + .font(title_large()) + .color(ON_SURFACE), + ); + }); + ui.add_space(16.0); +} + +/// Done-screen list row: leading icon (no container) + wrapped body. +pub fn done_list_row(ui: &mut Ui, icon: &str, content: impl FnOnce(&mut Ui)) { + let row_w = ui.available_width(); + let icon_col = LIST_ICON_SIZE; + let gap = CHOICE_CARD_ICON_GAP; + let text_w = (row_w - icon_col - gap).max(1.0); + + let row = ui.horizontal_top(|ui| { + ui.set_width(row_w); + ui.allocate_exact_size(Vec2::new(icon_col + gap, 0.0), Sense::hover()); + ui.vertical(|ui| { + ui.set_width(text_w); + content(ui); + }); + }); + + let row_rect = row.response.rect; + ui.painter().text( + Pos2::new(row_rect.left() + icon_col * 0.5, row_rect.center().y), + egui::Align2::CENTER_CENTER, + icon, + icons::font(LIST_ICON_SIZE), + ON_SURFACE_VARIANT, + ); +} + +/// Inline text link sized tightly to its label (no extra hit padding). +pub fn inline_link(ui: &mut Ui, label: &str, hover_enabled: bool) -> bool { + let galley = ui.painter().layout( + label.to_owned(), + body(14.0), + PRIMARY, + f32::INFINITY, + ); + let size = galley.size(); + let (rect, response) = ui.allocate_exact_size(size, Sense::click()); + let hovered = hover_enabled && response.hovered(); + ui.painter().galley(rect.min, galley, PRIMARY); + if hovered { + let y = rect.bottom() - 1.0; + ui.painter().hline( + rect.left()..=rect.right(), + y, + Stroke::new(1.0_f32, PRIMARY), + ); + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + response.clicked() +} + +pub fn section_title(ui: &mut Ui, title: &str) { + ui.add_space(4.0); + ui.label( + egui::RichText::new(title) + .font(title_large()) + .color(ON_SURFACE), + ); + ui.add_space(12.0); +} + +pub fn field_label(ui: &mut Ui, label: &str) { + ui.label( + egui::RichText::new(label) + .font(label_small()) + .color(ON_SURFACE_VARIANT), + ); + ui.add_space(FIELD_LABEL_GAP); +} + +fn choice_card_text_left() -> f32 { + CHOICE_CARD_PAD + LIST_ICON_CONTAINER_SIZE + CHOICE_CARD_ICON_GAP +} + +fn choice_card_text_width(width: f32) -> f32 { + (width - choice_card_text_left() - CHOICE_CARD_PAD).max(80.0) +} + +pub fn choice_card_height(width: f32, subtitle: &str, painter: &egui::Painter) -> f32 { + let text_w = choice_card_text_width(width); + let subtitle_galley = painter.layout( + subtitle.to_owned(), + body(12.0), + ON_SURFACE_VARIANT, + text_w, + ); + (16.0 + 20.0 + subtitle_galley.size().y + 16.0).max(72.0) +} + +pub fn choice_card_sized( + ui: &mut Ui, + width: f32, + title: &str, + subtitle: &str, + icon: &str, + hover_enabled: bool, +) -> bool { + let text_left = choice_card_text_left(); + let text_w = choice_card_text_width(width); + let subtitle_galley = ui.painter().layout( + subtitle.to_owned(), + body(12.0), + ON_SURFACE_VARIANT, + text_w, + ); + let card_h = (16.0 + 20.0 + subtitle_galley.size().y + 16.0).max(72.0); + let (rect, response) = ui.allocate_exact_size(Vec2::new(width, card_h), Sense::click()); + let hovered = hover_enabled && response.hovered(); + let pressed = hover_enabled && response.is_pointer_button_down_on(); + let (hover_t, press_t) = interaction_anim(ui, response.id, hovered, pressed); + + let rest = SURFACE_CONTAINER; + let hover = SURFACE_CONTAINER_HIGH; + let mut bg = theme::lerp_color(rest, hover, hover_t); + if press_t > 0.001 { + bg = theme::lerp_color(bg, Color32::from_rgba_unmultiplied(0xD0, 0xBC, 0xFF, 20), press_t); + } + let rest_r = 12.0; + let hover_r = (card_h * 0.5).min(28.0); + let rounding = Rounding::same(rest_r + (hover_r - rest_r) * hover_t); + ui.painter().rect_filled(rect, rounding, bg); + + let icon_rect = Rect::from_min_size( + Pos2::new(rect.left() + CHOICE_CARD_PAD, rect.top() + (card_h - LIST_ICON_CONTAINER_SIZE) * 0.5), + Vec2::splat(LIST_ICON_CONTAINER_SIZE), + ); + theme::draw_tonal_icon_container(ui.painter(), icon_rect); + ui.painter().text( + icon_rect.center(), + egui::Align2::CENTER_CENTER, + icon, + icons::font(LIST_ICON_SIZE), + PRIMARY, + ); + ui.painter().text( + rect.left_top() + Vec2::new(text_left, 16.0), + egui::Align2::LEFT_TOP, + title, + body_medium(16.0), + ON_SURFACE, + ); + ui.painter().galley( + rect.left_top() + Vec2::new(text_left, 38.0), + subtitle_galley, + ON_SURFACE_VARIANT, + ); + response.clicked() +} + +pub fn selectable_choice_card( + ui: &mut Ui, + title: &str, + subtitle: &str, + icon_texture: Option<&TextureHandle>, + fallback_icon: &str, + selected: bool, + hover_enabled: bool, +) -> bool { + let width = ui.available_width(); + let text_left = choice_card_text_left(); + let text_w = choice_card_text_width(width); + let subtitle_galley = ui.painter().layout( + subtitle.to_owned(), + body(12.0), + ON_SURFACE_VARIANT, + text_w, + ); + let card_h = (16.0 + 20.0 + subtitle_galley.size().y + 16.0).max(72.0); + let (rect, response) = ui.allocate_exact_size(Vec2::new(width, card_h), Sense::click()); + let hovered = hover_enabled && response.hovered(); + let pressed = hover_enabled && response.is_pointer_button_down_on(); + let (hover_t, press_t) = interaction_anim(ui, response.id, hovered, pressed); + + let rest = if selected { + SURFACE_CONTAINER_HIGH + } else { + SURFACE_CONTAINER + }; + let hover = SURFACE_CONTAINER_HIGH; + let mut bg = theme::lerp_color(rest, hover, hover_t); + if press_t > 0.001 { + bg = theme::lerp_color(bg, Color32::from_rgba_unmultiplied(0xD0, 0xBC, 0xFF, 20), press_t); + } + let rest_r = 12.0; + let hover_r = (card_h * 0.5).min(28.0); + let rounding = Rounding::same(rest_r + (hover_r - rest_r) * hover_t); + ui.painter().rect_filled(rect, rounding, bg); + if selected { + ui.painter().rect_stroke( + rect, + rounding, + Stroke::new(2.0_f32, PRIMARY), + ); + } + + let icon_rect = Rect::from_min_size( + Pos2::new( + rect.left() + CHOICE_CARD_PAD, + rect.top() + (card_h - LIST_ICON_CONTAINER_SIZE) * 0.5, + ), + Vec2::splat(LIST_ICON_CONTAINER_SIZE), + ); + draw_choice_card_icon(ui, icon_rect, icon_texture, fallback_icon); + ui.painter().text( + rect.left_top() + Vec2::new(text_left, 16.0), + egui::Align2::LEFT_TOP, + title, + body_medium(16.0), + ON_SURFACE, + ); + ui.painter().galley( + rect.left_top() + Vec2::new(text_left, 38.0), + subtitle_galley, + ON_SURFACE_VARIANT, + ); + response.clicked() +} + +/// Read-only install copy summary (upgrade confirm, etc.) — surface container, no border. +pub fn install_copy_card( + ui: &mut Ui, + title: &str, + subtitle: &str, + icon_texture: Option<&TextureHandle>, + fallback_icon: &str, +) { + let width = ui.available_width(); + let text_left = choice_card_text_left(); + let text_w = choice_card_text_width(width); + let subtitle_galley = ui.painter().layout( + subtitle.to_owned(), + body(12.0), + ON_SURFACE_VARIANT, + text_w, + ); + let card_h = (16.0 + 20.0 + subtitle_galley.size().y + 16.0).max(72.0); + let (rect, _) = ui.allocate_exact_size(Vec2::new(width, card_h), Sense::hover()); + let rounding = Rounding::same(12.0); + ui.painter().rect_filled(rect, rounding, SURFACE_CONTAINER); + + let icon_rect = Rect::from_min_size( + Pos2::new( + rect.left() + CHOICE_CARD_PAD, + rect.top() + (card_h - LIST_ICON_CONTAINER_SIZE) * 0.5, + ), + Vec2::splat(LIST_ICON_CONTAINER_SIZE), + ); + draw_choice_card_icon(ui, icon_rect, icon_texture, fallback_icon); + ui.painter().text( + rect.left_top() + Vec2::new(text_left, 16.0), + egui::Align2::LEFT_TOP, + title, + body_medium(16.0), + ON_SURFACE, + ); + ui.painter().galley( + rect.left_top() + Vec2::new(text_left, 38.0), + subtitle_galley, + ON_SURFACE_VARIANT, + ); +} + +pub fn error_label(ui: &mut Ui, message: &str) { + ui.add_space(8.0); + ui.colored_label(ERROR, message); +} diff --git a/app/desktop/windows-setup/setup/src/win_chrome.rs b/app/desktop/windows-setup/setup/src/win_chrome.rs new file mode 100644 index 0000000..9468cf7 --- /dev/null +++ b/app/desktop/windows-setup/setup/src/win_chrome.rs @@ -0,0 +1,37 @@ +//! Native Windows 11 window chrome (rounded corners via DWM). + +#[cfg(windows)] +pub fn apply(frame: &eframe::Frame) { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use windows::Win32::Foundation::HWND; + use windows::Win32::Graphics::Dwm::{ + DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWM_WINDOW_CORNER_PREFERENCE, + }; + + let Ok(handle) = frame.window_handle() else { + crate::win_log::warn("win_chrome: no window handle"); + return; + }; + let RawWindowHandle::Win32(win32) = handle.as_raw() else { + return; + }; + let hwnd = HWND(win32.hwnd.get() as _); + // DWMWCP_ROUND — opt in to Win11 rounded corners for borderless custom frames. + let preference = DWM_WINDOW_CORNER_PREFERENCE(2); + let result = unsafe { + DwmSetWindowAttribute( + hwnd, + DWMWA_WINDOW_CORNER_PREFERENCE, + &preference as *const _ as *const _, + std::mem::size_of::() as u32, + ) + }; + if let Err(error) = result { + crate::win_log::warn(&format!("win_chrome: DwmSetWindowAttribute failed: {error:?}")); + } else { + crate::win_log::info("win_chrome: DWMWCP_ROUND applied"); + } +} + +#[cfg(not(windows))] +pub fn apply(_frame: &eframe::Frame) {} diff --git a/app/desktop/windows-setup/setup/src/win_console.rs b/app/desktop/windows-setup/setup/src/win_console.rs new file mode 100644 index 0000000..35fd000 --- /dev/null +++ b/app/desktop/windows-setup/setup/src/win_console.rs @@ -0,0 +1,22 @@ +//! Console attachment for CLI-driven setup (`--upgrade`, `--uninstall`). + +#[cfg(windows)] +pub fn configure() { + use windows::Win32::System::Console::{AttachConsole, ATTACH_PARENT_PROCESS}; + + let cli_mode = std::env::args().any(|arg| { + matches!( + arg.as_str(), + fromchat_setup_common::UPGRADE_ARG | fromchat_setup_common::UNINSTALL_ARG + ) + }); + + if cli_mode { + unsafe { + let _ = AttachConsole(ATTACH_PARENT_PROCESS); + } + } +} + +#[cfg(not(windows))] +pub fn configure() {} diff --git a/app/desktop/windows-setup/setup/src/win_dialog.rs b/app/desktop/windows-setup/setup/src/win_dialog.rs new file mode 100644 index 0000000..85b1fcc --- /dev/null +++ b/app/desktop/windows-setup/setup/src/win_dialog.rs @@ -0,0 +1,84 @@ +//! Native Windows message boxes (separate modal window, system chrome). + +#[cfg(windows)] +mod imp { + use windows::core::PCWSTR; + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{ + MessageBoxW, MB_DEFBUTTON2, MB_ICONERROR, MB_ICONWARNING, MB_OK, MB_YESNO, IDNO, IDYES, + }; + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + + pub fn confirm_close(hwnd: HWND) -> bool { + let title = wide(crate::i18n::CLOSE_CONFIRM_TITLE); + let text = wide(crate::i18n::CLOSE_CONFIRM_PROMPT); + let result = unsafe { + MessageBoxW( + hwnd, + PCWSTR(text.as_ptr()), + PCWSTR(title.as_ptr()), + MB_YESNO | MB_ICONWARNING | MB_DEFBUTTON2, + ) + }; + result == IDYES + } + + pub fn show_operation_error(hwnd: HWND, title: &str, message: &str) { + let title = wide(title); + let text = wide(message); + unsafe { + let _ = MessageBoxW( + hwnd, + PCWSTR(text.as_ptr()), + PCWSTR(title.as_ptr()), + MB_OK | MB_ICONERROR, + ); + } + } + + pub fn show_fatal_operation_error(hwnd: HWND, title: &str, message: &str) { + show_operation_error(hwnd, title, message); + } + + pub fn show_message(title: &str, text: &str) { + let title = wide(title); + let text = wide(text); + unsafe { + let _ = MessageBoxW( + HWND::default(), + PCWSTR(text.as_ptr()), + PCWSTR(title.as_ptr()), + MB_OK | MB_ICONERROR, + ); + } + } +} + +#[cfg(windows)] +pub use imp::{confirm_close, show_fatal_operation_error, show_message}; + +#[cfg(not(windows))] +pub fn confirm_close() -> bool { + true +} + +#[cfg(not(windows))] +pub fn show_fatal_operation_error(_title: &str, _message: &str) {} + +#[cfg(not(windows))] +pub fn show_message(_title: &str, _text: &str) {} + +#[cfg(windows)] +pub fn hwnd_from_frame(frame: &eframe::Frame) -> windows::Win32::Foundation::HWND { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use windows::Win32::Foundation::HWND; + if let Ok(handle) = frame.window_handle() { + if let RawWindowHandle::Win32(win32) = handle.as_raw() { + return HWND(win32.hwnd.get() as _); + } + } + HWND::default() +} diff --git a/app/desktop/windows-setup/setup/src/win_log.rs b/app/desktop/windows-setup/setup/src/win_log.rs new file mode 100644 index 0000000..030e994 --- /dev/null +++ b/app/desktop/windows-setup/setup/src/win_log.rs @@ -0,0 +1,163 @@ +//! Startup / panic logging to a local file, debugger output, and the Windows Application event log. + +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Mutex; + +const EVENT_SOURCE: &str = "FromChat Setup"; + +static LOG_FILE: Mutex> = Mutex::new(None); + +pub fn init() { + if let Ok(mut guard) = LOG_FILE.lock() { + if guard.is_some() { + return; + } + let path = log_file_path(); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + *guard = Some(path); + } + + std::panic::set_hook(Box::new(|info| { + let payload = info + .payload() + .downcast_ref::<&str>() + .map(|s| (*s).to_string()) + .or_else(|| { + info.payload() + .downcast_ref::() + .map(|s| s.clone()) + }) + .unwrap_or_else(|| "unknown panic payload".to_owned()); + let location = info + .location() + .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) + .unwrap_or_else(|| "unknown location".to_owned()); + error(&format!("PANIC at {location}: {payload}")); + })); + + info("FromChat Setup starting"); + info(&format!("log file: {}", log_file_path().display())); + if let Ok(exe) = std::env::current_exe() { + info(&format!("exe: {}", exe.display())); + } + info(&format!("args: {:?}", std::env::args().collect::>())); +} + +pub fn info(message: &str) { + write("INFO", message); +} + +pub fn warn(message: &str) { + write("WARN", message); +} + +pub fn error(message: &str) { + write("ERROR", message); +} + +fn write(level: &str, message: &str) { + let line = format!( + "{} [{level}] {message}\n", + chrono_lite_timestamp() + ); + + if let Ok(guard) = LOG_FILE.lock() { + if let Some(path) = guard.as_ref() { + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { + let _ = file.write_all(line.as_bytes()); + let _ = file.flush(); + } + } + } + + #[cfg(windows)] + report_event(level, message); + + #[cfg(not(windows))] + { + let _ = (level, message); + } +} + +fn log_file_path() -> PathBuf { + std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) + .join("FromChat") + .join("logs") + .join("setup.log") +} + +/// Tiny timestamp without pulling in chrono. +fn chrono_lite_timestamp() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + format!("unix={secs}") +} + +#[cfg(windows)] +fn report_event(level: &str, message: &str) { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + + use windows::core::PCWSTR; + use windows::Win32::System::Diagnostics::Debug::OutputDebugStringW; + use windows::Win32::System::EventLog::{ + DeregisterEventSource, RegisterEventSourceW, ReportEventW, EVENTLOG_ERROR_TYPE, + EVENTLOG_INFORMATION_TYPE, EVENTLOG_WARNING_TYPE, + }; + + let debug_line: Vec = OsStr::new(&format!("[{EVENT_SOURCE}] [{level}] {message}")) + .encode_wide() + .chain([0]) + .collect(); + unsafe { + OutputDebugStringW(PCWSTR(debug_line.as_ptr())); + } + + let event_type = match level { + "ERROR" => EVENTLOG_ERROR_TYPE, + "WARN" => EVENTLOG_WARNING_TYPE, + _ => EVENTLOG_INFORMATION_TYPE, + }; + + let source: Vec = EVENT_SOURCE.encode_utf16().chain([0]).collect(); + let body: Vec = message.encode_utf16().chain([0]).collect(); + + unsafe { + let Ok(handle) = RegisterEventSourceW(None, PCWSTR(source.as_ptr())) else { + return; + }; + if handle.is_invalid() { + return; + } + let strings = [PCWSTR(body.as_ptr())]; + let _ = ReportEventW( + handle, + event_type, + 0, + 0, + None, + 0, + Some(&strings), + None, + ); + let _ = DeregisterEventSource(handle); + } +} + +pub fn log_file_hint() -> String { + LOG_FILE + .lock() + .ok() + .and_then(|g| g.clone()) + .map(|p| p.display().to_string()) + .unwrap_or_else(|| log_file_path().display().to_string()) +} diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.jvm.kt index 74fa5eb..29ea4d6 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.jvm.kt @@ -1,17 +1,11 @@ package ru.fromchat.api.local.cache +import com.pr0gramm3r101.utils.files.PlatformFileSystem import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -private fun cacheRoot(): File { - val home = System.getProperty("user.home") - return if (!home.isNullOrBlank()) { - File(home, ".fromchat/cache") - } else { - File(System.getProperty("java.io.tmpdir"), "fromchat") - } -} +private fun cacheRoot(): File = File(PlatformFileSystem.getAppCacheDirectory()) actual suspend fun wipeFromChatCacheDirectory() { withContext(Dispatchers.IO) { diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheGeneration.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheGeneration.jvm.kt index 321a8eb..589253f 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheGeneration.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheGeneration.jvm.kt @@ -1,5 +1,6 @@ package ru.fromchat.api.local.cache +import com.pr0gramm3r101.utils.files.PlatformFileSystem import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -7,15 +8,8 @@ import ru.fromchat.api.local.db.store.MessageDatabaseProvider private const val GENERATION_FILE = ".generation" -private fun fromChatRoot(): File { - val home = System.getProperty("user.home") - val base = if (!home.isNullOrBlank()) { - File(home, ".fromchat/cache") - } else { - File(System.getProperty("java.io.tmpdir"), "fromchat") - } - return File(base, "fromchat") -} +private fun fromChatRoot(): File = + File(PlatformFileSystem.getAppCacheDirectory(), "fromchat") actual suspend fun ensureFromChatCacheGeneration() { withContext(Dispatchers.IO) { diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/ResumableUploadStorage.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/ResumableUploadStorage.jvm.kt index 2497563..86020e9 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/ResumableUploadStorage.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/ResumableUploadStorage.jvm.kt @@ -1,5 +1,6 @@ package ru.fromchat.api.local.cache +import com.pr0gramm3r101.utils.files.PlatformFileSystem import java.io.File import java.io.FileInputStream import java.io.FileOutputStream @@ -8,14 +9,7 @@ import java.net.URI import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -private fun cacheRoot(): File { - val home = System.getProperty("user.home") - return if (!home.isNullOrBlank()) { - File(home, ".fromchat/cache") - } else { - File(System.getProperty("java.io.tmpdir"), "fromchat") - } -} +private fun cacheRoot(): File = File(PlatformFileSystem.getAppCacheDirectory()) private fun uploadDir(instanceId: String): File { val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_") diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.jvm.kt index 7ebbdc8..31f0c33 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.jvm.kt @@ -2,16 +2,12 @@ package ru.fromchat.api.local.db.store import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import com.pr0gramm3r101.utils.files.PlatformFileSystem import java.io.File import ru.fromchat.db.MessageDatabase actual fun provideMessageDatabaseDriver(): SqlDriver { - val home = System.getProperty("user.home") - val dir = if (!home.isNullOrBlank()) { - File(home, ".fromchat/cache/fromchat") - } else { - File(System.getProperty("java.io.tmpdir"), "fromchat") - } + val dir = File(PlatformFileSystem.getAppCacheDirectory(), "fromchat") dir.mkdirs() val dbFile = File(dir, "message_database.db") val needsCreate = !dbFile.exists() diff --git a/scripts/build-windows-vm.cmd b/scripts/build-windows-vm.cmd new file mode 100644 index 0000000..28ca978 --- /dev/null +++ b/scripts/build-windows-vm.cmd @@ -0,0 +1,53 @@ +@echo off +setlocal EnableExtensions + +if /I "%1"=="sync" ( + call "%~dp0sync-windows-vm.cmd" %2 + exit /b %ERRORLEVEL% +) + +call "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvarsall.bat" arm64 +if errorlevel 1 exit /b 1 +set "JAVA_HOME_17=C:\Program Files\Eclipse Adoptium\jdk-17.0.20.8-hotspot" +set "JAVA_HOME_21=C:\Program Files\Eclipse Adoptium\jdk-21.0.10.7-hotspot" +for /d %%D in ("C:\Program Files\Eclipse Adoptium\jdk-17*") do set "JAVA_HOME_17=%%D" +for /d %%D in ("C:\Program Files\Eclipse Adoptium\jdk-21*") do set "JAVA_HOME_21=%%D" +set "JAVA_HOME=%JAVA_HOME_21%" +set "FROMCHAT_PACKAGING_JDK=%JAVA_HOME_17%" +set "PATH=%JAVA_HOME%\bin;C:\WINDOWS\system32\config\systemprofile\.cargo\bin;%PATH%" +cd /d C:\FromChat\app +if exist gradle\gradle-daemon-jvm.properties del /f gradle\gradle-daemon-jvm.properties + +if /I "%1"=="rust" ( + call "%~dp0sync-windows-vm.cmd" rust + if errorlevel 1 exit /b 1 + cd app\desktop\windows-setup + cargo build --release --workspace + exit /b %ERRORLEVEL% +) + +if /I "%1"=="pack" ( + call "%~dp0sync-windows-vm.cmd" rust + if errorlevel 1 exit /b 1 + if exist gradle\gradle-daemon-jvm.properties del /f gradle\gradle-daemon-jvm.properties + gradlew.bat :app:desktop:packSetupOnly --no-daemon + if errorlevel 1 exit /b 1 + call "%~dp0copy-installers-to-desktop.cmd" + exit /b %ERRORLEVEL% +) + +if /I "%1"=="gradle" ( + call "%~dp0sync-windows-vm.cmd" gradle + if errorlevel 1 exit /b 1 + if exist gradle\gradle-daemon-jvm.properties del /f gradle\gradle-daemon-jvm.properties + gradlew.bat :app:desktop:packageReleaseWindows --no-daemon + if errorlevel 1 exit /b 1 + call "%~dp0copy-installers-to-desktop.cmd" + exit /b %ERRORLEVEL% +) + +echo Usage: build-windows-vm.cmd sync [all^|rust^|scripts^|gradle] +echo build-windows-vm.cmd rust ^(sync windows-setup, then cargo build^) +echo build-windows-vm.cmd pack ^(sync, rust, packSetupOnly — fast UI loop^) +echo build-windows-vm.cmd gradle ^(sync, full packageReleaseWindows, copy to Desktop^) +exit /b 1 diff --git a/scripts/copy-installers-to-desktop.cmd b/scripts/copy-installers-to-desktop.cmd new file mode 100644 index 0000000..0f86009 --- /dev/null +++ b/scripts/copy-installers-to-desktop.cmd @@ -0,0 +1,83 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion + +rem Copy packaged Windows installer to the logged-in user's Desktop. +rem prlctl exec runs as SYSTEM, so do not rely on %%USERPROFILE%%. + +set "DIST=C:\FromChat\app\app\desktop\build\distributions\release" +set "WIN_DESKTOP=" +set "COPIED=0" + +if not exist "%DIST%\" ( + echo WARNING: Release output folder missing: %DIST% + exit /b 0 +) + +for /f "skip=1 tokens=1" %%U in ('query user 2^>nul') do ( + if not defined LOGON set "LOGON=%%U" +) +if defined LOGON set "WIN_DESKTOP=C:\Users\%LOGON%\Desktop" + +if not exist "%WIN_DESKTOP%\" ( + for /d %%D in ("C:\Users\*") do ( + if not defined WIN_DESKTOP ( + echo %%~nxD | findstr /i /x "Public Default DefaultAccount WsiAccount" >nul + if errorlevel 1 if exist "%%D\Desktop\" set "WIN_DESKTOP=%%D\Desktop" + ) + ) +) + +if not defined WIN_DESKTOP ( + echo ERROR: Could not resolve Windows user Desktop path + exit /b 1 +) + +call :kill_running_installers + +for %%F in ("%DIST%\FromChat-Portable-*.exe") do ( + if exist "%%F" ( + del /F /Q "%%F" >nul 2>&1 + echo Removed stale portable build output: %%~nxF + ) +) + +for %%F in ("%WIN_DESKTOP%\FromChat-Portable-*.exe") do ( + if exist "%%F" ( + del /F /Q "%%F" >nul 2>&1 + echo Removed stale portable artifact: %%~nxF + ) +) + +for %%F in ("%DIST%\FromChat-Setup-*.exe") do ( + if exist "%%F" ( + call :copy_installer "%%~fF" "%WIN_DESKTOP%" + if errorlevel 1 exit /b 1 + set "COPIED=1" + ) +) + +if "!COPIED!"=="0" ( + echo WARNING: No FromChat-Setup-*.exe found in %DIST% +) + +exit /b 0 + +:kill_running_installers +echo Stopping running FromChat installer processes... +powershell -NoProfile -Command ^ + "$names = @('fromchat-setup','FromChat-Setup*','FromChat-Portable*');" ^ + "foreach ($n in $names) { Get-Process -Name $n -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue }" ^ + 2>nul +ping -n 2 127.0.0.1 >nul +exit /b 0 + +:copy_installer +set "SRC=%~1" +set "DEST_DIR=%~2" +copy /Y "%SRC%" "%DEST_DIR%\" >nul 2>&1 +if errorlevel 1 ( + echo ERROR: Failed to copy %~nx1 to %DEST_DIR% — close the running installer and retry + exit /b 1 +) +echo Copied %~nx1 -^> %DEST_DIR% +exit /b 0 diff --git a/scripts/install-windows-vm-tools.ps1 b/scripts/install-windows-vm-tools.ps1 new file mode 100644 index 0000000..24caebf --- /dev/null +++ b/scripts/install-windows-vm-tools.ps1 @@ -0,0 +1,99 @@ +$ErrorActionPreference = "Stop" + +function Ensure-Jdk17 { + $adoptium = "C:\Program Files\Eclipse Adoptium" + $jdkHome = Get-ChildItem $adoptium -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like "jdk-17*" } | + Sort-Object Name -Descending | + Select-Object -First 1 + if ($jdkHome) { return $jdkHome.FullName } + Write-Host "Downloading Temurin JDK 17..." + $msi = Join-Path $env:TEMP "temurin-jdk17.msi" + Invoke-WebRequest ` + -Uri "https://api.adoptium.net/v3/installer/latest/17/ga/windows/x64/jdk/hotspot/normal/eclipse?project=jdk" ` + -OutFile $msi ` + -UseBasicParsing + Write-Host "Installing Temurin JDK 17..." + $args = @("/i", $msi, "/qn", "ADDLOCAL=FeatureMain,FeatureEnvironment,FeatureJarFileRunWith,FeatureJavaHome") + $p = Start-Process msiexec.exe -ArgumentList $args -Wait -PassThru + if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) { throw "JDK 17 installer exit code $($p.ExitCode)" } + $jdkHome = Get-ChildItem $adoptium -Directory | Where-Object { $_.Name -like "jdk-17*" } | Sort-Object Name -Descending | Select-Object -First 1 + if (-not $jdkHome) { throw "JDK 17 install failed" } + return $jdkHome.FullName +} + +function Ensure-Jdk21 { + $adoptium = "C:\Program Files\Eclipse Adoptium" + $jdkHome = Get-ChildItem $adoptium -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like "jdk-21*" } | + Sort-Object Name -Descending | + Select-Object -First 1 + if ($jdkHome) { return $jdkHome.FullName } + Write-Host "Downloading Temurin JDK 21..." + $msi = Join-Path $env:TEMP "temurin-jdk21.msi" + Invoke-WebRequest ` + -Uri "https://api.adoptium.net/v3/installer/latest/21/ga/windows/x64/jdk/hotspot/normal/eclipse?project=jdk" ` + -OutFile $msi ` + -UseBasicParsing + Write-Host "Installing Temurin JDK 21..." + $args = @("/i", $msi, "/qn", "ADDLOCAL=FeatureMain,FeatureEnvironment,FeatureJarFileRunWith,FeatureJavaHome") + $p = Start-Process msiexec.exe -ArgumentList $args -Wait -PassThru + if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) { throw "JDK 21 installer exit code $($p.ExitCode)" } + $jdkHome = Get-ChildItem $adoptium -Directory | Where-Object { $_.Name -like "jdk-21*" } | Sort-Object Name -Descending | Select-Object -First 1 + if (-not $jdkHome) { throw "JDK 21 install failed" } + return $jdkHome.FullName +} + +function Ensure-Rust { + $cargo = Join-Path $env:USERPROFILE ".cargo\bin\cargo.exe" + if (Test-Path $cargo) { return $cargo } + Write-Host "Downloading rustup..." + $rustup = Join-Path $env:TEMP "rustup-init.exe" + Invoke-WebRequest -Uri "https://win.rustup.rs/x86_64" -OutFile $rustup -UseBasicParsing + Write-Host "Installing Rust..." + $p = Start-Process $rustup -ArgumentList @("-y", "--default-toolchain", "stable", "--profile", "minimal") -Wait -PassThru + if ($p.ExitCode -ne 0) { throw "rustup exit code $($p.ExitCode)" } + if (-not (Test-Path $cargo)) { throw "cargo not found after rustup" } + return $cargo +} + +function Ensure-VsBuildTools { + $link = Get-Command link.exe -ErrorAction SilentlyContinue + if ($link) { return $link.Source } + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vswhere) { + $installPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null + if ($installPath) { + $candidate = Join-Path $installPath "VC\Tools\MSVC" + if (Test-Path $candidate) { return $installPath } + } + } + Write-Host "Downloading VS Build Tools..." + $bootstrapper = Join-Path $env:TEMP "vs_BuildTools.exe" + Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vs_BuildTools.exe" -OutFile $bootstrapper -UseBasicParsing + Write-Host "Installing VS Build Tools (this may take several minutes)..." + $args = @( + "--quiet", "--wait", "--norestart", + "--add", "Microsoft.VisualStudio.Workload.VCTools", + "--includeRecommended" + ) + $p = Start-Process $bootstrapper -ArgumentList $args -Wait -PassThru + if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) { + throw "VS Build Tools exit code $($p.ExitCode)" + } + $link = Get-Command link.exe -ErrorAction SilentlyContinue + if (-not $link) { + throw "link.exe still not found after VS Build Tools install" + } + return $link.Source +} + +$javaHome17 = Ensure-Jdk17 +Write-Host "JAVA_HOME_17=$javaHome17" +$javaHome21 = Ensure-Jdk21 +Write-Host "JAVA_HOME_21=$javaHome21" +$cargo = Ensure-Rust +Write-Host "CARGO=$cargo" +$link = Ensure-VsBuildTools +Write-Host "LINK=$link" +Write-Host "OK" diff --git a/scripts/sync-to-windows-vm.sh b/scripts/sync-to-windows-vm.sh new file mode 100755 index 0000000..86ed49f --- /dev/null +++ b/scripts/sync-to-windows-vm.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +VM_NAME="${FROMCHAT_WINDOWS_VM:-Windows 11}" +SCOPE="${1:-all}" + +if ! command -v prlctl >/dev/null 2>&1; then + echo "prlctl not found — run sync-windows-vm.cmd inside the VM instead" >&2 + exit 1 +fi + +prlctl exec "$VM_NAME" cmd /c "C:\\FromChat\\app\\scripts\\sync-windows-vm.cmd ${SCOPE}" diff --git a/scripts/sync-windows-vm.cmd b/scripts/sync-windows-vm.cmd new file mode 100644 index 0000000..df7bbb9 --- /dev/null +++ b/scripts/sync-windows-vm.cmd @@ -0,0 +1,51 @@ +@echo off +setlocal EnableExtensions + +rem Sync Mac workspace share -> local VM copy. +rem Uses /E (not /MIR) so VM-only build caches (target, build, .gradle) are never deleted. + +set "SRC=C:\Mac\Home\Desktop\FromChat\app" +set "DEST=C:\FromChat\app" + +if not exist "%SRC%\" ( + echo ERROR: Mac share not found at %SRC% + echo Enable Parallels shared folders or adjust SRC in sync-windows-vm.cmd + exit /b 1 +) + +if not exist "%DEST%\" mkdir "%DEST%" + +set "OPTS=/E /R:2 /W:2 /NP /NFL /NDL" +set "CACHE=/XD target build .gradle node_modules .git" +set "SKIP=/XF gradle-daemon-jvm.properties" + +if /I "%~1"=="rust" ( + echo Syncing app\desktop\windows-setup ^(keeping target\^)... + robocopy "%SRC%\app\desktop\windows-setup" "%DEST%\app\desktop\windows-setup" %OPTS% /XD target + goto :finish +) + +if /I "%~1"=="scripts" ( + echo Syncing scripts... + robocopy "%SRC%\scripts" "%DEST%\scripts" %OPTS% + goto :finish +) + +if /I "%~1"=="gradle" ( + echo Syncing app tree for Gradle ^(keeping build caches^)... + robocopy "%SRC%" "%DEST%" %OPTS% %CACHE% %SKIP% + goto :finish +) + +echo Syncing full app tree ^(preserving target, build, .gradle^)... +robocopy "%SRC%" "%DEST%" %OPTS% %CACHE% %SKIP% +goto :finish + +:finish +set "RC=%ERRORLEVEL%" +rem Robocopy: 0-7 = success (0 = nothing to copy, 1+ = copied files) +if %RC% GEQ 8 ( + echo robocopy failed with exit code %RC% + exit /b %RC% +) +exit /b 0 diff --git a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/AppDataRoot.jvm.kt b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/AppDataRoot.jvm.kt new file mode 100644 index 0000000..2dc26bf --- /dev/null +++ b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/AppDataRoot.jvm.kt @@ -0,0 +1,118 @@ +package com.pr0gramm3r101.utils.files + +import java.io.File + +/** + * Resolves the FromChat desktop data/cache root on the JVM. + * + * - Portable (`-Dfromchat.portable=true`): `/fromchat-data` + * - Windows installed: `%LOCALAPPDATA%\FromChat` + * - macOS: `~/Library/Application Support/FromChat` + * - Linux: `${XDG_DATA_HOME:-~/.local/share}/FromChat` + * + * Migrates once from the legacy `~/.fromchat/cache` directory when present. + */ +internal object AppDataRoot { + private const val LEGACY_RELATIVE = ".fromchat/cache" + private const val PORTABLE_DIR_NAME = "fromchat-data" + private const val APP_DIR_NAME = "FromChat" + + @Volatile + private var cached: File? = null + + fun resolve(): File { + cached?.let { return it } + synchronized(this) { + cached?.let { return it } + val root = resolveUncached().also { it.mkdirs() } + migrateLegacyIfNeeded(root) + cached = root + return root + } + } + + private fun resolveUncached(): File { + if (isPortable()) { + return File(executableDirectory(), PORTABLE_DIR_NAME) + } + val os = System.getProperty("os.name").orEmpty().lowercase() + return when { + os.contains("win") -> windowsInstalledRoot() + os.contains("mac") -> macInstalledRoot() + else -> linuxInstalledRoot() + } + } + + private fun isPortable(): Boolean = + System.getProperty("fromchat.portable") + ?.equals("true", ignoreCase = true) == true + + private fun executableDirectory(): File { + System.getProperty("fromchat.exe.dir")?.takeIf { it.isNotBlank() }?.let { + return File(it).absoluteFile + } + // jpackage layout: /runtime → parent is the install/portable folder + val javaHome = System.getProperty("java.home") + if (!javaHome.isNullOrBlank()) { + val runtime = File(javaHome).absoluteFile + if (runtime.name.equals("runtime", ignoreCase = true)) { + val parent = runtime.parentFile + if (parent != null) return parent + } + } + return File(System.getProperty("user.dir") ?: ".").absoluteFile + } + + private fun windowsInstalledRoot(): File { + val localAppData = System.getenv("LOCALAPPDATA")?.takeIf { it.isNotBlank() } + return if (localAppData != null) { + File(localAppData, APP_DIR_NAME) + } else { + File(requireHome(), APP_DIR_NAME) + } + } + + private fun macInstalledRoot(): File = + File(requireHome(), "Library/Application Support/$APP_DIR_NAME") + + private fun linuxInstalledRoot(): File { + val xdg = System.getenv("XDG_DATA_HOME")?.takeIf { it.isNotBlank() } + return if (xdg != null) { + File(xdg, APP_DIR_NAME) + } else { + File(requireHome(), ".local/share/$APP_DIR_NAME") + } + } + + private fun requireHome(): File { + val home = System.getProperty("user.home") + return if (!home.isNullOrBlank()) { + File(home) + } else { + File(System.getProperty("java.io.tmpdir") ?: ".", "fromchat-home") + } + } + + private fun legacyCacheDir(): File? { + val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() } ?: return null + return File(home, LEGACY_RELATIVE) + } + + private fun migrateLegacyIfNeeded(target: File) { + if (isPortable()) return + val legacy = legacyCacheDir() ?: return + if (!legacy.isDirectory) return + val marker = File(target, ".migrated-from-legacy-cache") + if (marker.isFile) return + val targetEmpty = target.list().isNullOrEmpty() + if (!targetEmpty) { + marker.writeText("skipped-non-empty\n") + return + } + runCatching { + legacy.copyRecursively(target, overwrite = false) + marker.writeText("ok\n") + legacy.deleteRecursively() + } + } +} diff --git a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.jvm.kt b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.jvm.kt index c50d6fd..292a55e 100644 --- a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.jvm.kt +++ b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.jvm.kt @@ -49,14 +49,8 @@ internal actual fun expectListFileNamesInDirectory(dirPath: String): List