Windows installer

This commit is contained in:
2026-08-24 14:47:14 +03:00
Unverified
parent d25cb7e57a
commit 529e090b25
61 changed files with 7395 additions and 49 deletions
+148
View File
@@ -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 }}
+19
View File
@@ -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
+19
View File
@@ -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
View File
@@ -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") }
}
}
+1
View File
@@ -0,0 +1 @@
target/
+31
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
# Программа установки FromChat
*Честно признаюсь, это 100% вайбкод. Rust я не знаю. Просьба не предьявлять претензии по этому поводу.*
Это программа для установки FromChat на Windows. У нее есть 2 режима: портативная версия (распакует в папку) и полная (с регистрацией, ярлыком на рабочем столе и т.д.).
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

@@ -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,
}
+713
View File
@@ -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 &registry_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"
+37
View File
@@ -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)
}
+13
View File
@@ -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",
] }
+40
View File
@@ -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));
}
+157
View File
@@ -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(&registration_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())
}
@@ -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) {
@@ -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) {
@@ -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,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()
+53
View File
@@ -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
+83
View File
@@ -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
+99
View File
@@ -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"
+12
View File
@@ -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}"
+51
View File
@@ -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()
}
}
}
@@ -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()