mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-23 19:45:04 +03:00
Windows installer
This commit is contained in:
@@ -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 }}
|
||||
@@ -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-<ver>-macOS.dmg` |
|
||||
| `./gradlew :app:desktop:packageReleaseLinux` | Linux | `.deb`, `.rpm`, `.AppImage` (needs `appimagetool`) |
|
||||
| `./gradlew :app:desktop:packageReleaseWindows` | Windows | `FromChat-Setup-<ver>.exe`, `FromChat-Portable-<ver>.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
|
||||
|
||||
@@ -29,6 +29,25 @@ FromChat — 100% бесплатный и открытый мессенджер.
|
||||
|
||||
Desktop заменяет бывший Electron-клиент в репозитории Web. Запуск: `./gradlew :app:desktop:run`.
|
||||
|
||||
### Сборка Desktop
|
||||
|
||||
Релизные пакеты собираются на соответствующей ОС (или в CI):
|
||||
|
||||
| Задача | Хост | Артефакты |
|
||||
|--------|------|-----------|
|
||||
| `./gradlew :app:desktop:packageReleaseMac` | macOS | `FromChat-<ver>-macOS.dmg` |
|
||||
| `./gradlew :app:desktop:packageReleaseLinux` | Linux | `.deb`, `.rpm`, `.AppImage` (нужен `appimagetool`) |
|
||||
| `./gradlew :app:desktop:packageReleaseWindows` | Windows | `FromChat-Setup-<ver>.exe`, `FromChat-Portable-<ver>.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` при наличии).
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Технологический стек
|
||||
|
||||
+260
-10
@@ -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<Exec>("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<Exec>("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<Exec>("packSetupOnly") {
|
||||
group = "compose desktop"
|
||||
description = "Repack setup EXE from existing app-image + Rust (skips ProGuard)."
|
||||
onlyIf { runningOnWindows }
|
||||
dependsOn(buildWindowsSetupRust)
|
||||
configureWindowsPackTask()
|
||||
}
|
||||
|
||||
tasks.register<Exec>("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") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
target/
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
# Программа установки FromChat
|
||||
|
||||
*Честно признаюсь, это 100% вайбкод. Rust я не знаю. Просьба не предьявлять претензии по этому поводу.*
|
||||
|
||||
Это программа для установки FromChat на Windows. У нее есть 2 режима: портативная версия (распакует в папку) и полная (с регистрацией, ярлыком на рабочем столе и т.д.).
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>FromChat installer welcome backdrop</title>
|
||||
<style>
|
||||
/* Exact tokens from dmg-background/styles.css */
|
||||
:root {
|
||||
--brand-conic: conic-gradient(
|
||||
from 0deg,
|
||||
rgba(147, 51, 234, 0.5) 0%,
|
||||
rgba(99, 102, 241, 0.6) 12.5%,
|
||||
rgba(59, 130, 246, 0.55) 25%,
|
||||
rgba(168, 85, 247, 0.5) 37.5%,
|
||||
rgba(217, 70, 239, 0.6) 50%,
|
||||
rgba(236, 72, 153, 0.55) 62.5%,
|
||||
rgba(192, 132, 252, 0.5) 75%,
|
||||
rgba(126, 34, 206, 0.6) 87.5%,
|
||||
rgba(147, 51, 234, 0.5) 100%
|
||||
);
|
||||
--surface: rgb(21, 18, 24);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Installer window size */
|
||||
#bg {
|
||||
position: relative;
|
||||
width: 480px;
|
||||
height: 520px;
|
||||
overflow: hidden;
|
||||
background-color: var(--surface);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/*
|
||||
Same structure as DMG `.brand` + `.brand__logo-wrap::before`.
|
||||
Title bar is 40px in the installer; DMG brand starts at 28px under Finder chrome.
|
||||
*/
|
||||
.brand {
|
||||
position: absolute;
|
||||
top: 68px; /* 40 title + 28 brand inset */
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand__logo-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 68px;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* Exact copy of dmg-background/styles.css `.brand__logo-wrap::before` */
|
||||
.brand__logo-wrap::before {
|
||||
content: "";
|
||||
background: var(--brand-conic);
|
||||
position: absolute;
|
||||
width: 1200px;
|
||||
height: 1200px;
|
||||
bottom: -180px;
|
||||
opacity: 0.48;
|
||||
left: calc(50% - 600px);
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="bg"></div>
|
||||
<script>
|
||||
// Mount brand glow the same way as the DMG markup.
|
||||
const bg = document.getElementById("bg");
|
||||
bg.innerHTML =
|
||||
'<header class="brand"><div class="brand__logo-wrap"></div></header>';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
@@ -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",
|
||||
] }
|
||||
@@ -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<PathBuf> {
|
||||
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<Vec<u8>> {
|
||||
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)
|
||||
}
|
||||
@@ -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<u8>,
|
||||
}
|
||||
|
||||
/// 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::<i32>() {
|
||||
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<IconRgba> {
|
||||
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<IconRgba> {
|
||||
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<IconRgba> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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<u8>,
|
||||
pub launcher: Vec<u8>,
|
||||
pub payload_zstd: Vec<u8>,
|
||||
}
|
||||
|
||||
#[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<Self> {
|
||||
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<EmbeddedBundle> {
|
||||
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<Vec<u8>> {
|
||||
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<PathBuf> {
|
||||
Ok(std::env::current_exe()?)
|
||||
}
|
||||
@@ -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<String>,
|
||||
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,
|
||||
}
|
||||
@@ -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<u16> {
|
||||
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<Self> {
|
||||
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<String>,
|
||||
pub edition: FromChatEdition,
|
||||
pub display_icon: Option<String>,
|
||||
}
|
||||
|
||||
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<PathBuf> {
|
||||
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<u64> {
|
||||
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<InstalledFromChat> {
|
||||
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<InstalledFromChat> {
|
||||
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<InstalledFromChat> {
|
||||
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<InstalledFromChat>, 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<InstalledFromChat> {
|
||||
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::<PROCESSENTRY32W>() 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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<String> {
|
||||
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<u16> = 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::<TOKEN_ELEVATION>() 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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
if all_users {
|
||||
known_folder(FOLDERID_CommonPrograms)
|
||||
} else {
|
||||
known_folder(FOLDERID_Programs)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn desktop_folder() -> Result<PathBuf> {
|
||||
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<Self> {
|
||||
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<String> {
|
||||
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<HANDLE> {
|
||||
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;
|
||||
@@ -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"
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<FromChatEdition> {
|
||||
match raw {
|
||||
"release" => Ok(FromChatEdition::Release),
|
||||
"beta" => Ok(FromChatEdition::Beta),
|
||||
_ => anyhow::bail!("unknown edition {raw}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_app_exe(dest: &PathBuf) -> Result<PathBuf> {
|
||||
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<Vec<PathBuf>> {
|
||||
let mut out = Vec::new();
|
||||
fn rec(dir: &PathBuf, out: &mut Vec<PathBuf>) -> 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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<PathBuf> {
|
||||
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<Vec<PathBuf>> {
|
||||
let mut out = Vec::new();
|
||||
fn rec(dir: &PathBuf, out: &mut Vec<PathBuf>) -> 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)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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",
|
||||
] }
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<S: Copy> {
|
||||
pub from: S,
|
||||
pub to: S,
|
||||
pub t: f32,
|
||||
pub duration: f32,
|
||||
}
|
||||
|
||||
impl<S: Copy> ScreenTransition<S> {
|
||||
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())
|
||||
}
|
||||
@@ -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<SetupOptions, SetupLaunchError> {
|
||||
let args: Vec<String> = 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,
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
];
|
||||
@@ -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],
|
||||
];
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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<dyn std::error::Error + Send + Sync>
|
||||
})?;
|
||||
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<egui::IconData> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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<Pos2> = 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<Pos2>, 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<f64> = 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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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::<DWM_WINDOW_CORNER_PREFERENCE>() 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) {}
|
||||
@@ -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() {}
|
||||
@@ -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<u16> {
|
||||
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()
|
||||
}
|
||||
@@ -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<Option<PathBuf>> = 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::<String>()
|
||||
.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::<Vec<_>>()));
|
||||
}
|
||||
|
||||
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<u16> = 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<u16> = EVENT_SOURCE.encode_utf16().chain([0]).collect();
|
||||
let body: Vec<u16> = 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())
|
||||
}
|
||||
+2
-8
@@ -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) {
|
||||
|
||||
+3
-9
@@ -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) {
|
||||
|
||||
+2
-8
@@ -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._-]"), "_")
|
||||
|
||||
+2
-6
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Executable
+12
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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`): `<exeDir>/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: <app>/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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-8
@@ -49,14 +49,8 @@ internal actual fun expectListFileNamesInDirectory(dirPath: String): List<String
|
||||
return dir.list()?.toList().orEmpty()
|
||||
}
|
||||
|
||||
internal actual fun expectGetAppCacheDirectory(): String {
|
||||
val home = System.getProperty("user.home")
|
||||
return if (!home.isNullOrBlank()) {
|
||||
File(home, ".fromchat/cache").absolutePath
|
||||
} else {
|
||||
File(System.getProperty("java.io.tmpdir"), "fromchat").absolutePath
|
||||
}
|
||||
}
|
||||
internal actual fun expectGetAppCacheDirectory(): String =
|
||||
AppDataRoot.resolve().absolutePath
|
||||
|
||||
internal actual fun expectEnsureDirectory(path: String) {
|
||||
File(path).mkdirs()
|
||||
|
||||
Reference in New Issue
Block a user