mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +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 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
|
## 🏗️ Tech Stack
|
||||||
|
|||||||
@@ -29,6 +29,25 @@ FromChat — 100% бесплатный и открытый мессенджер.
|
|||||||
|
|
||||||
Desktop заменяет бывший Electron-клиент в репозитории Web. Запуск: `./gradlew :app:desktop:run`.
|
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 {
|
fun resolvePackagingJdkHome(): String {
|
||||||
System.getenv("FROMCHAT_PACKAGING_JDK")?.takeIf { it.isNotBlank() }?.let { return it }
|
System.getenv("FROMCHAT_PACKAGING_JDK")?.takeIf { it.isNotBlank() }?.let { return it }
|
||||||
val candidates = listOf(
|
System.getenv("JAVA_HOME")?.takeIf { it.isNotBlank() }?.let { home ->
|
||||||
"${System.getProperty("user.home")}/.gradle/jdks/eclipse_adoptium-17-aarch64-os_x.2/jdk-17.0.19+10/Contents/Home",
|
if (File(home, "bin/jpackage").isFile || File(home, "bin/jpackage.exe").isFile) {
|
||||||
"/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home",
|
return home
|
||||||
"/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home",
|
}
|
||||||
"/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/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 {
|
compose.desktop {
|
||||||
@@ -58,11 +84,29 @@ compose.desktop {
|
|||||||
|
|
||||||
nativeDistributions {
|
nativeDistributions {
|
||||||
packageName = "FromChat"
|
packageName = "FromChat"
|
||||||
packageVersion = "1.0.0"
|
packageVersion = rootProject.extra["versionName"] as String
|
||||||
description = "FromChat desktop"
|
description = "FromChat desktop"
|
||||||
copyright = "© FromChat"
|
copyright = "© FromChat"
|
||||||
includeAllModules = true
|
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(
|
modules(
|
||||||
"javafx.base",
|
"javafx.base",
|
||||||
"javafx.graphics",
|
"javafx.graphics",
|
||||||
@@ -482,3 +526,209 @@ tasks.register("packageDmg") {
|
|||||||
description = "Alias for packageReleaseDmg (full release DMG pipeline)."
|
description = "Alias for packageReleaseDmg (full release DMG pipeline)."
|
||||||
dependsOn("packageReleaseDmg")
|
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
|
package ru.fromchat.api.local.cache
|
||||||
|
|
||||||
|
import com.pr0gramm3r101.utils.files.PlatformFileSystem
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
private fun cacheRoot(): File {
|
private fun cacheRoot(): File = File(PlatformFileSystem.getAppCacheDirectory())
|
||||||
val home = System.getProperty("user.home")
|
|
||||||
return if (!home.isNullOrBlank()) {
|
|
||||||
File(home, ".fromchat/cache")
|
|
||||||
} else {
|
|
||||||
File(System.getProperty("java.io.tmpdir"), "fromchat")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun wipeFromChatCacheDirectory() {
|
actual suspend fun wipeFromChatCacheDirectory() {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|||||||
+3
-9
@@ -1,5 +1,6 @@
|
|||||||
package ru.fromchat.api.local.cache
|
package ru.fromchat.api.local.cache
|
||||||
|
|
||||||
|
import com.pr0gramm3r101.utils.files.PlatformFileSystem
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -7,15 +8,8 @@ import ru.fromchat.api.local.db.store.MessageDatabaseProvider
|
|||||||
|
|
||||||
private const val GENERATION_FILE = ".generation"
|
private const val GENERATION_FILE = ".generation"
|
||||||
|
|
||||||
private fun fromChatRoot(): File {
|
private fun fromChatRoot(): File =
|
||||||
val home = System.getProperty("user.home")
|
File(PlatformFileSystem.getAppCacheDirectory(), "fromchat")
|
||||||
val base = if (!home.isNullOrBlank()) {
|
|
||||||
File(home, ".fromchat/cache")
|
|
||||||
} else {
|
|
||||||
File(System.getProperty("java.io.tmpdir"), "fromchat")
|
|
||||||
}
|
|
||||||
return File(base, "fromchat")
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun ensureFromChatCacheGeneration() {
|
actual suspend fun ensureFromChatCacheGeneration() {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|||||||
+2
-8
@@ -1,5 +1,6 @@
|
|||||||
package ru.fromchat.api.local.cache
|
package ru.fromchat.api.local.cache
|
||||||
|
|
||||||
|
import com.pr0gramm3r101.utils.files.PlatformFileSystem
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
@@ -8,14 +9,7 @@ import java.net.URI
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
private fun cacheRoot(): File {
|
private fun cacheRoot(): File = File(PlatformFileSystem.getAppCacheDirectory())
|
||||||
val home = System.getProperty("user.home")
|
|
||||||
return if (!home.isNullOrBlank()) {
|
|
||||||
File(home, ".fromchat/cache")
|
|
||||||
} else {
|
|
||||||
File(System.getProperty("java.io.tmpdir"), "fromchat")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun uploadDir(instanceId: String): File {
|
private fun uploadDir(instanceId: String): File {
|
||||||
val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
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.db.SqlDriver
|
||||||
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
|
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
|
||||||
|
import com.pr0gramm3r101.utils.files.PlatformFileSystem
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import ru.fromchat.db.MessageDatabase
|
import ru.fromchat.db.MessageDatabase
|
||||||
|
|
||||||
actual fun provideMessageDatabaseDriver(): SqlDriver {
|
actual fun provideMessageDatabaseDriver(): SqlDriver {
|
||||||
val home = System.getProperty("user.home")
|
val dir = File(PlatformFileSystem.getAppCacheDirectory(), "fromchat")
|
||||||
val dir = if (!home.isNullOrBlank()) {
|
|
||||||
File(home, ".fromchat/cache/fromchat")
|
|
||||||
} else {
|
|
||||||
File(System.getProperty("java.io.tmpdir"), "fromchat")
|
|
||||||
}
|
|
||||||
dir.mkdirs()
|
dir.mkdirs()
|
||||||
val dbFile = File(dir, "message_database.db")
|
val dbFile = File(dir, "message_database.db")
|
||||||
val needsCreate = !dbFile.exists()
|
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()
|
return dir.list()?.toList().orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
internal actual fun expectGetAppCacheDirectory(): String {
|
internal actual fun expectGetAppCacheDirectory(): String =
|
||||||
val home = System.getProperty("user.home")
|
AppDataRoot.resolve().absolutePath
|
||||||
return if (!home.isNullOrBlank()) {
|
|
||||||
File(home, ".fromchat/cache").absolutePath
|
|
||||||
} else {
|
|
||||||
File(System.getProperty("java.io.tmpdir"), "fromchat").absolutePath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal actual fun expectEnsureDirectory(path: String) {
|
internal actual fun expectEnsureDirectory(path: String) {
|
||||||
File(path).mkdirs()
|
File(path).mkdirs()
|
||||||
|
|||||||
Reference in New Issue
Block a user