Improve the installer, fix context menu on Windows, add CI workflows

This commit is contained in:
2026-09-02 23:03:07 +03:00
Unverified
parent 529e090b25
commit 33f5ac1a1f
66 changed files with 3384 additions and 768 deletions
-148
View File
@@ -1,148 +0,0 @@
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 }}
+343
View File
@@ -0,0 +1,343 @@
name: 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: release-${{ github.ref }}-${{ inputs.version || github.ref_name }}
cancel-in-progress: true
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -Dkotlin.daemon.jvm.options=-Xmx6g
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-arm64:
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 version
run: bash scripts/ci-set-version.sh "${{ needs.resolve-version.outputs.version }}"
- name: Install create-dmg
run: brew install create-dmg
- name: Package macOS Apple Silicon
run: |
export FROMCHAT_PACKAGING_JDK="$JAVA_HOME"
./gradlew :app:desktop:packageReleaseMac -PdesktopArch=arm64 --no-daemon
- uses: actions/upload-artifact@v4
with:
name: macos-arm64
path: app/desktop/build/distributions/release/*-macOS-arm64.dmg
if-no-files-found: error
macos-x64:
needs: resolve-version
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
architecture: x64
- name: Set version
run: bash scripts/ci-set-version.sh "${{ needs.resolve-version.outputs.version }}"
- name: Install create-dmg
run: brew install create-dmg
- name: Package macOS Intel
run: |
export FROMCHAT_PACKAGING_JDK="$JAVA_HOME"
./gradlew :app:desktop:packageReleaseMac -PdesktopArch=x64 --no-daemon
- uses: actions/upload-artifact@v4
with:
name: macos-x64
path: app/desktop/build/distributions/release/*-macOS-x64.dmg
if-no-files-found: error
linux-x64:
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 version
run: bash scripts/ci-set-version.sh "${{ needs.resolve-version.outputs.version }}"
- name: Package Linux x64
run: |
export FROMCHAT_PACKAGING_JDK="$JAVA_HOME"
./gradlew :app:desktop:packageReleaseLinux -PdesktopArch=x64 --no-daemon
- uses: actions/upload-artifact@v4
with:
name: linux-x64
path: |
app/desktop/build/distributions/release/*-linux-x64.deb
app/desktop/build/distributions/release/*-linux-x64.rpm
app/desktop/build/distributions/release/*-linux-x64.AppImage
if-no-files-found: error
linux-arm64:
needs: resolve-version
runs-on: ubuntu-24.04-arm
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-aarch64.AppImage -O /tmp/appimagetool
chmod +x /tmp/appimagetool
echo "APPIMAGETOOL=/tmp/appimagetool" >> "$GITHUB_ENV"
- name: Set version
run: bash scripts/ci-set-version.sh "${{ needs.resolve-version.outputs.version }}"
- name: Package Linux ARM64
run: |
export FROMCHAT_PACKAGING_JDK="$JAVA_HOME"
./gradlew :app:desktop:packageReleaseLinux -PdesktopArch=arm64 --no-daemon
- uses: actions/upload-artifact@v4
with:
name: linux-arm64
path: |
app/desktop/build/distributions/release/*-linux-arm64.deb
app/desktop/build/distributions/release/*-linux-arm64.rpm
app/desktop/build/distributions/release/*-linux-arm64.AppImage
if-no-files-found: error
windows-x64:
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 version
shell: bash
run: bash scripts/ci-set-version.sh "${{ needs.resolve-version.outputs.version }}"
- name: Package Windows x64
shell: bash
run: |
export FROMCHAT_PACKAGING_JDK="$JAVA_HOME"
./gradlew :app:desktop:packageReleaseWindows -PdesktopArch=x64 --no-daemon
- uses: actions/upload-artifact@v4
with:
name: windows-x64
path: app/desktop/build/distributions/release/*-windows-x64.exe
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: windows-x64-app-image
path: app/desktop/build/compose/binaries/main-release/app/FromChat
if-no-files-found: error
windows-arm64:
needs: resolve-version
runs-on: windows-11-arm
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: dtolnay/rust-toolchain@stable
- name: Set version
shell: bash
run: bash scripts/ci-set-version.sh "${{ needs.resolve-version.outputs.version }}"
- name: Package Windows ARM64
shell: bash
run: |
export FROMCHAT_PACKAGING_JDK="$JAVA_HOME"
./gradlew :app:desktop:packageReleaseWindows -PdesktopArch=arm64 --no-daemon
- uses: actions/upload-artifact@v4
with:
name: windows-arm64
path: app/desktop/build/distributions/release/*-windows-arm64.exe
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: windows-arm64-app-image
path: app/desktop/build/compose/binaries/main-release/app/FromChat
if-no-files-found: error
android:
needs: resolve-version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: android-actions/setup-android@v3
- name: Set version
run: bash scripts/ci-set-version.sh "${{ needs.resolve-version.outputs.version }}"
- name: Prepare Android signing
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
GOOGLE_SERVICES_JSON: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON }}
RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_STORE_PASSWORD }}
RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
run: |
if [[ -z "$KEYSTORE_BASE64" || -z "$GOOGLE_SERVICES_JSON" ]]; then
echo "Missing ANDROID_KEYSTORE_BASE64 or ANDROID_GOOGLE_SERVICES_JSON secrets" >&2
exit 1
fi
mkdir -p app/android/keys
printf '%s' "$KEYSTORE_BASE64" | base64 -d > app/android/keys/release.jks
keytool -genkeypair -noprompt \
-alias key0 -keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=FromChat Debug" \
-keystore app/android/keys/debug.jks \
-storepass android -keypass android
cat > app/android/keys/keystore.properties <<EOF
releaseStorePassword=${RELEASE_STORE_PASSWORD}
releaseKeyPassword=${RELEASE_KEY_PASSWORD}
debugStorePassword=android
debugKeyPassword=android
EOF
printf '%s' "$GOOGLE_SERVICES_JSON" | base64 -d > app/android/google-services.json
- name: Build universal Android APK
run: ./gradlew :app:android:assembleRelease --no-daemon
- name: Stage APK
run: |
mkdir -p release-staging
APK="$(find app/android/build/outputs/apk/release -name '*release*.apk' | head -n 1)"
cp "$APK" "release-staging/FromChat-${{ needs.resolve-version.outputs.version }}-android-universal.apk"
- uses: actions/upload-artifact@v4
with:
name: android
path: release-staging/*.apk
if-no-files-found: error
ios:
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 version
run: bash scripts/ci-set-version.sh "${{ needs.resolve-version.outputs.version }}"
- name: Package iOS IPA
run: |
mkdir -p release-staging
bash scripts/package-ios-ipa.sh "${{ needs.resolve-version.outputs.version }}" release-staging
- uses: actions/upload-artifact@v4
with:
name: ios
path: release-staging/*.ipa
if-no-files-found: error
publish:
needs:
- resolve-version
- macos-arm64
- macos-x64
- linux-x64
- linux-arm64
- windows-x64
- windows-arm64
- android
- ios
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: artifacts
- name: Collect release assets
run: |
mkdir -p release-assets
find artifacts -type f \( \
-name '*.dmg' -o \
-name '*.deb' -o \
-name '*.rpm' -o \
-name '*.AppImage' -o \
-name '*.exe' -o \
-name '*.apk' -o \
-name '*.ipa' \
\) -exec cp {} release-assets/ \;
ls -la release-assets
for pattern in \
'*-windows-x64.exe' \
'*-windows-arm64.exe' \
'*-macOS-x64.dmg' \
'*-macOS-arm64.dmg' \
'*-linux-x64.deb' \
'*-linux-arm64.deb' \
'*-linux-x64.rpm' \
'*-linux-arm64.rpm' \
'*-linux-x64.AppImage' \
'*-linux-arm64.AppImage' \
'*-android-universal.apk' \
'*-ios.ipa'
do
if ! compgen -G "release-assets/$pattern" > /dev/null; then
echo "Missing release asset: $pattern" >&2
exit 1
fi
done
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.resolve-version.outputs.tag }}
name: FromChat ${{ needs.resolve-version.outputs.version }}
draft: false
generate_release_notes: true
files: release-assets/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+6 -6
View File
@@ -33,12 +33,12 @@ Desktop replaces the former Electron client in the Web repo. Run with `./gradlew
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 |
| 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/`.
+445 -44
View File
@@ -4,16 +4,30 @@ plugins {
alias(libs.plugins.compose.compiler)
}
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.zip.ZipFile
val javafxVersion = libs.versions.openjfx.get()
val javafxClassifier = openjfxClassifier()
val javafxModules = listOf("base", "graphics", "controls", "media", "web", "swing")
dependencies {
implementation(compose.desktop.currentOs)
if (isWindowsArm64Host()) {
val composeDesktopVersion = libs.versions.compose.multiplatform.get()
implementation("org.jetbrains.compose.desktop:desktop-jvm-windows-arm64:$composeDesktopVersion")
implementation("org.jetbrains.skiko:skiko-awt-runtime-windows-arm64:0.150.1")
implementation("org.jetbrains.skiko:skiko-awt-runtime-angle-windows-arm64:0.150.1")
} else {
implementation(compose.desktop.currentOs)
}
implementation(compose.material3)
implementation(compose.materialIconsExtended)
implementation(libs.compose.components.resources)
implementation(project(":app:shared"))
implementation(project(":utils:shared"))
implementation(libs.kotlinx.coroutines.swing)
implementation("net.java.dev.jna:jna-platform:5.15.0")
// OpenJFX publishes empty jars without a classifier; declare every module
// with the host classifier and exclude transitive stubs.
javafxModules.forEach { module ->
@@ -23,7 +37,14 @@ dependencies {
}
}
configurations.configureEach {
if (isWindowsArm64Host()) {
exclude(group = "org.jetbrains.skiko", module = "skiko-awt-runtime-windows-x64")
}
}
val desktopWindowIconPng = layout.projectDirectory.file("src/main/resources/app_window_icon.png")
val desktopWindowIconIco = layout.projectDirectory.file("icons/app_window_icon.ico")
val desktopWindowIconIcns = layout.projectDirectory.file("icons/app_window_icon.icns")
val dmgBackgroundDir = layout.projectDirectory.dir("dmg-background")
val dmgStageDir = layout.buildDirectory.dir("dmg-background/stage")
@@ -33,15 +54,149 @@ val releaseAppBundle = layout.buildDirectory.dir("compose/binaries/main-release/
val testDmgOutput = layout.buildDirectory.file("distributions/FromChat-test.dmg")
val releaseDmgOutput = layout.buildDirectory.file("distributions/FromChat.dmg")
private fun hostCpuArch(): String {
val arch = System.getProperty("os.arch").orEmpty().lowercase()
if (arch.contains("aarch64") || arch == "arm64") return "aarch64"
val nativeArch = System.getenv("PROCESSOR_ARCHITEW6432")?.uppercase()
?: System.getenv("PROCESSOR_ARCHITECTURE")?.uppercase()
if (nativeArch == "ARM64") return "aarch64"
return when {
arch.contains("amd64") || arch == "x86_64" -> "x86_64"
else -> arch
}
}
private fun isWindowsArm64Host(): Boolean {
if (project.findProperty("windowsArm64") != null) return true
val os = System.getProperty("os.name").orEmpty().lowercase()
return os.contains("win") && hostCpuArch() == "aarch64"
}
/** CI / packaging label: `x64` or `arm64`. Override with `-PdesktopArch=…`. */
fun desktopReleaseArchLabel(): String {
project.findProperty("desktopArch")?.toString()?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
return when (hostCpuArch()) {
"aarch64" -> "arm64"
else -> "x64"
}
}
private fun syncWindowsArm64SkikoNatives(appDir: File, classpath: Iterable<File>) {
appDir.listFiles()
?.filter { it.name.startsWith("skiko", ignoreCase = true) && it.name.contains("x64", ignoreCase = true) }
?.forEach { it.delete() }
val arm64Runtime = classpath.firstOrNull {
it.name.contains("skiko-awt-runtime-windows-arm64", ignoreCase = true)
} ?: error("Missing skiko-awt-runtime-windows-arm64 on runtime classpath")
arm64Runtime.copyTo(appDir.resolve(arm64Runtime.name), overwrite = true)
ZipFile(arm64Runtime).use { zip ->
zip.entries().asIterator().forEach { entry ->
if (entry.isDirectory) return@forEach
val fileName = File(entry.name).name
if (!fileName.startsWith("skiko", ignoreCase = true)) return@forEach
zip.getInputStream(entry).use { input ->
appDir.resolve(fileName).outputStream().use { output -> input.copyTo(output) }
}
}
}
val angleRuntime = classpath.firstOrNull {
it.name.contains("skiko-awt-runtime-angle-windows-arm64", ignoreCase = true)
} ?: error("Missing skiko-awt-runtime-angle-windows-arm64 on runtime classpath")
angleRuntime.copyTo(appDir.resolve(angleRuntime.name), overwrite = true)
ZipFile(angleRuntime).use { zip ->
zip.entries().asIterator().forEach { entry ->
if (entry.isDirectory) return@forEach
val fileName = File(entry.name).name
if (!fileName.endsWith(".dll", ignoreCase = true)) return@forEach
zip.getInputStream(entry).use { input ->
appDir.resolve(fileName).outputStream().use { output -> input.copyTo(output) }
}
}
}
}
private fun windowsSkikoJvmArgs(): List<String> =
if (isWindowsArm64Host()) {
// Windows ARM64: OpenGL is unsupported; ANGLE (D3D11) is the supported GPU path in Skiko.
listOf("-Dskiko.rendering.angle.enabled=true")
} else {
listOf("-Dskiko.renderApi=OPENGL", "-Dsun.java2d.d3d=true")
}
private fun jdkReportsArch(javaExe: File): String? {
if (!javaExe.isFile) return null
val props = ProcessBuilder(javaExe.absolutePath, "-XshowSettings:properties", "-version")
.redirectErrorStream(true)
.start()
.inputStream
.bufferedReader()
.readText()
val line = props.lineSequence().firstOrNull { it.trimStart().startsWith("os.arch =") }
return line?.substringAfter("=")?.trim()
}
private val PE_MACHINE_AMD64 = 0x8664
private val PE_MACHINE_ARM64 = 0xAA64
private fun peMachineType(file: File): Int {
val bytes = file.readBytes()
check(bytes.size >= 0x40) { "PE file too small: ${file.absolutePath}" }
val peOffset = ByteBuffer.wrap(bytes, 0x3C, 4).order(ByteOrder.LITTLE_ENDIAN).int
check(peOffset in 0 until bytes.size - 6) { "Invalid PE offset in ${file.name}" }
return ByteBuffer.wrap(bytes, peOffset + 4, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() and 0xFFFF
}
private fun expectedPeMachine(): Int =
if (isWindowsArm64Host()) PE_MACHINE_ARM64 else PE_MACHINE_AMD64
private fun assertWindowsBinaryArch(file: File, label: String) {
val actual = peMachineType(file)
val expected = expectedPeMachine()
check(actual == expected) {
"$label has wrong architecture (PE machine=0x${actual.toString(16)}, " +
"expected=0x${expected.toString(16)}): ${file.absolutePath}"
}
}
private fun assertWindowsAppImageArch(appImageRoot: File) {
if (!System.getProperty("os.name").orEmpty().lowercase().contains("win")) return
val launcher = appImageRoot.resolve("${appImageRoot.name}.exe")
val jli = appImageRoot.resolve("runtime/bin/jli.dll")
check(launcher.isFile) { "Missing app launcher: $launcher" }
check(jli.isFile) { "Missing bundled JRE: $jli" }
assertWindowsBinaryArch(launcher, "App launcher")
assertWindowsBinaryArch(jli, "Bundled JRE (jli.dll)")
}
fun resolvePackagingJdkHome(): String {
System.getenv("FROMCHAT_PACKAGING_JDK")?.takeIf { it.isNotBlank() }?.let { return it }
System.getenv("FROMCHAT_PACKAGING_JDK")?.takeIf { it.isNotBlank() }?.let { candidate ->
if (isWindowsArm64Host()) {
val arch = jdkReportsArch(File(candidate, "bin/java.exe"))
check(arch == "aarch64") {
"FROMCHAT_PACKAGING_JDK must be ARM64 on Windows ARM64 (os.arch=$arch): $candidate"
}
}
return candidate
}
System.getenv("JAVA_HOME")?.takeIf { it.isNotBlank() }?.let { home ->
if (File(home, "bin/jpackage").isFile || File(home, "bin/jpackage.exe").isFile) {
if (isWindowsArm64Host()) {
val arch = jdkReportsArch(File(home, "bin/java.exe"))
check(arch == "aarch64") {
"JAVA_HOME must be ARM64 on Windows ARM64 (os.arch=$arch): $home"
}
}
return home
}
}
val userHome = System.getProperty("user.home")
val os = System.getProperty("os.name").orEmpty().lowercase()
val wantArm = hostCpuArch() == "aarch64"
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")
@@ -51,12 +206,22 @@ fun resolvePackagingJdkHome(): String {
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")
if (wantArm) {
add("C:/Program Files/Eclipse Adoptium/jdk-21.0.12.101-hotspot-arm64")
add("C:/Program Files/Eclipse Adoptium/jdk-17.0.20.8-hotspot-arm64")
}
File("C:/Program Files/Eclipse Adoptium").takeIf { it.isDirectory }?.listFiles()
?.filter { it.isDirectory && (!wantArm || it.name.contains("arm64", ignoreCase = true)) }
?.sortedByDescending { it.name }
?.forEach { add(it.absolutePath.replace('\\', '/')) }
add("C:/Program Files/Java/jdk-17")
add("C:/Program Files/Microsoft/jdk-17")
}
if (os.contains("linux")) {
if (wantArm) {
add("/usr/lib/jvm/temurin-17-jdk-aarch64")
add("/usr/lib/jvm/java-17-openjdk-aarch64")
}
add("/usr/lib/jvm/temurin-17-jdk-amd64")
add("/usr/lib/jvm/temurin-21-jdk-amd64")
add("/usr/lib/jvm/java-17-openjdk-amd64")
@@ -66,10 +231,18 @@ fun resolvePackagingJdkHome(): String {
}
System.getProperty("java.home")?.let { add(it) }
}
return candidates.firstOrNull {
File(it, "bin/jpackage").isFile || File(it, "bin/jpackage.exe").isFile
return candidates.firstOrNull { home ->
val jpackage = File(home, "bin/jpackage.exe").takeIf { it.isFile }
?: File(home, "bin/jpackage").takeIf { it.isFile }
if (jpackage == null) return@firstOrNull false
if (!isWindowsArm64Host()) return@firstOrNull true
jdkReportsArch(File(home, "bin/java.exe")) == "aarch64"
} ?: error(
"No JDK with jpackage found. Install Temurin 17+ or set FROMCHAT_PACKAGING_JDK.",
if (isWindowsArm64Host()) {
"No ARM64 JDK with jpackage found. Run scripts\\ensure-windows-arm64-jdk.cmd or set FROMCHAT_PACKAGING_JDK."
} else {
"No JDK with jpackage found. Install Temurin 17+ or set FROMCHAT_PACKAGING_JDK."
},
)
}
@@ -85,7 +258,8 @@ compose.desktop {
nativeDistributions {
packageName = "FromChat"
packageVersion = rootProject.extra["versionName"] as String
description = "FromChat desktop"
description =
if (project.findProperty("betaDesktop") != null) "FromChat Beta" else "FromChat"
copyright = "© FromChat"
includeAllModules = true
@@ -121,7 +295,9 @@ compose.desktop {
iconFile.set(desktopWindowIconPng)
}
windows {
iconFile.set(desktopWindowIconPng)
// jpackage on Windows requires .ico; a PNG is ignored and the Java cup stays.
iconFile.set(desktopWindowIconIco)
jvmArgs(*windowsSkikoJvmArgs().toTypedArray())
}
macOS {
bundleID = "ru.fromchat.desktop"
@@ -234,15 +410,22 @@ fun JavaExec.configureFromChatDesktopJvm() {
}
}
jvmArgs(
dockIconArgs + listOf(
"-Dapple.awt.enableTemplateImages=true",
"--module-path",
javafxJars.joinToString(File.pathSeparator) { it.absolutePath },
"--add-modules",
"javafx.controls,javafx.web,javafx.swing,javafx.media,javafx.graphics,javafx.base",
"--add-opens",
"javafx.graphics/com.sun.javafx.application=ALL-UNNAMED",
),
dockIconArgs + buildList {
if (System.getProperty("os.name").orEmpty().lowercase().contains("win")) {
addAll(windowsSkikoJvmArgs())
}
addAll(
listOf(
"-Dapple.awt.enableTemplateImages=true",
"--module-path",
javafxJars.joinToString(File.pathSeparator) { it.absolutePath },
"--add-modules",
"javafx.controls,javafx.web,javafx.swing,javafx.media,javafx.graphics,javafx.base",
"--add-opens",
"javafx.graphics/com.sun.javafx.application=ALL-UNNAMED",
),
)
},
)
}
@@ -368,8 +551,7 @@ afterEvaluate {
private fun openjfxClassifier(): String {
val os = System.getProperty("os.name").lowercase()
val arch = System.getProperty("os.arch").lowercase()
val isArm = arch == "aarch64" || arch == "arm64"
val isArm = hostCpuArch() == "aarch64"
return when {
os.contains("mac") && isArm -> "mac-aarch64"
os.contains("mac") -> "mac"
@@ -533,9 +715,20 @@ val runningOnLinux = System.getProperty("os.name").orEmpty().lowercase().contain
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") }
val debugAppImageDir = layout.buildDirectory.dir("compose/binaries/main/app/FromChat")
val linuxAppImageOutput = desktopDistDir.map {
it.file("FromChat-$desktopVersionName-linux-${desktopReleaseArchLabel()}.AppImage")
}
val windowsSetupOutput = desktopDistDir.map {
it.file("FromChat-Setup-$desktopVersionName-windows-${desktopReleaseArchLabel()}.exe")
}
val windowsUniversalSetupOutput = desktopDistDir.map {
it.file("FromChat-Setup-$desktopVersionName-windows-universal.exe")
}
val windowsBetaSetupOutput = desktopDistDir.map { it.file("FromChat-Setup-$desktopVersionName-beta2.exe") }
val macDmgReleaseOutput = desktopDistDir.map {
it.file("FromChat-$desktopVersionName-macOS-${desktopReleaseArchLabel()}.dmg")
}
tasks.register("packageReleaseMac") {
group = "compose desktop"
@@ -631,7 +824,8 @@ tasks.register("packageReleaseLinux") {
binaries.walkTopDown()
.filter { it.isFile && (it.extension == "deb" || it.extension == "rpm") }
.forEach { file ->
val renamed = "FromChat-$desktopVersionName-linux.${file.extension}"
val arch = desktopReleaseArchLabel()
val renamed = "FromChat-$desktopVersionName-linux-$arch.${file.extension}"
file.copyTo(outDir.resolve(renamed), overwrite = true)
}
}
@@ -640,59 +834,137 @@ tasks.register("packageReleaseLinux") {
val windowsSetupDir = layout.projectDirectory.dir("windows-setup")
val windowsRustReleaseDir = windowsSetupDir.dir("target/release")
val windowsRustBinaryNames = listOf(
"fromchat-setup.exe",
"fromchat-setup-helper.exe",
"FromChat-Installer.exe",
"FromChat-Installer-Helper.exe",
"fromchat-portable-launcher.exe",
"fromchat-pack.exe",
"fromchat-icon-patch.exe",
)
fun findJpackageAppExe(appImageDir: File): File {
val direct = listOf(
appImageDir.resolve("FromChat.exe"),
appImageDir.resolve("app/FromChat.exe"),
)
direct.firstOrNull { it.isFile }?.let { return it }
return appImageDir.walkTopDown()
.first { file ->
file.isFile && file.name.equals("FromChat.exe", ignoreCase = true)
}
}
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")
environment("FROMCHAT_SETUP_VERSION", desktopVersionName)
inputs.property("setupVersion", desktopVersionName)
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("icon-patch"))
inputs.dir(windowsSetupDir.dir("assets"))
inputs.file(windowsSetupDir.file("Cargo.toml"))
doFirst {
val releaseDir = windowsRustReleaseDir.asFile
val keepNames = windowsRustBinaryNames.toSet()
if (releaseDir.isDirectory) {
releaseDir.listFiles()?.forEach { file ->
if (
file.isFile &&
file.extension.equals("exe", ignoreCase = true) &&
file.name !in keepNames
) {
file.delete()
}
}
}
}
windowsRustBinaryNames.forEach { name ->
outputs.file(windowsRustReleaseDir.file(name))
}
}
fun Exec.configureWindowsPackTask() {
inputs.dir(releaseAppImageDir).withPropertyName("appImage")
val patchWindowsJpackageIcon = tasks.register<Exec>("patchWindowsJpackageIcon") {
group = "compose desktop"
description = "Embed branded icon into jpackage FromChat.exe (Task Manager)."
onlyIf { runningOnWindows }
dependsOn(buildWindowsSetupRust)
mustRunAfter("createDistributable")
doFirst {
val appImageDir = debugAppImageDir.get().asFile
check(appImageDir.isDirectory) { "Missing app image at ${appImageDir.absolutePath}" }
val exe = findJpackageAppExe(appImageDir)
val patcher = windowsRustReleaseDir.asFile.resolve("fromchat-icon-patch.exe")
check(patcher.isFile) { "Missing $patcher — build windows-setup workspace first." }
commandLine(patcher.absolutePath, exe.absolutePath)
}
}
val patchWindowsJpackageReleaseIcon = tasks.register<Exec>("patchWindowsJpackageReleaseIcon") {
group = "compose desktop"
description = "Embed branded icon into release jpackage FromChat.exe."
onlyIf { runningOnWindows }
dependsOn(buildWindowsSetupRust)
mustRunAfter("createReleaseDistributable")
doFirst {
val appImageDir = releaseAppImageDir.get().asFile
check(appImageDir.isDirectory) { "Missing app image at ${appImageDir.absolutePath}" }
val exe = findJpackageAppExe(appImageDir)
val patcher = windowsRustReleaseDir.asFile.resolve("fromchat-icon-patch.exe")
check(patcher.isFile) { "Missing $patcher — build windows-setup workspace first." }
commandLine(patcher.absolutePath, exe.absolutePath)
}
}
val windowsPrebuiltX64AppImage = layout.buildDirectory.dir("prebuilt/windows-x64/app/FromChat")
val windowsPrebuiltArm64AppImage = layout.buildDirectory.dir("prebuilt/windows-arm64/app/FromChat")
fun Exec.configureWindowsPackTask(
appImageDir: org.gradle.api.provider.Provider<org.gradle.api.file.Directory>?,
setupOutput: org.gradle.api.provider.Provider<org.gradle.api.file.RegularFile>,
registrationId: String = "FromChat",
prebuiltOnly: Boolean = false,
) {
appImageDir?.let { inputs.dir(it).withPropertyName("appImage") }
inputs.dir(windowsRustReleaseDir).withPropertyName("windowsSetupRust")
inputs.property("packVersion", desktopVersionName)
outputs.file(windowsSetupOutput)
inputs.property("registrationId", registrationId)
inputs.property("prebuiltOnly", prebuiltOnly)
outputs.file(setupOutput)
doFirst {
val appDir = releaseAppImageDir.get().asFile
check(appDir.isDirectory) { "Missing ${appDir.absolutePath}" }
val outDir = desktopDistDir.get().asFile
outDir.mkdirs()
val nativeAppDir = appImageDir?.get()?.asFile
if (!prebuiltOnly) {
check(nativeAppDir != null && nativeAppDir.isDirectory) {
"Missing ${nativeAppDir?.absolutePath}"
}
}
val prebuiltX64 = windowsPrebuiltX64AppImage.get().asFile
val prebuiltArm64 = windowsPrebuiltArm64AppImage.get().asFile
desktopDistDir.get().asFile.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 setupBin = releaseDir.resolve("FromChat-Installer.exe").takeIf { it.isFile }
?: releaseDir.resolve("FromChat-Installer")
val helperBin = releaseDir.resolve("FromChat-Installer-Helper.exe").takeIf { it.isFile }
?: releaseDir.resolve("FromChat-Installer-Helper")
val launcherBin = releaseDir.resolve("fromchat-portable-launcher.exe").takeIf { it.isFile }
?: releaseDir.resolve("fromchat-portable-launcher")
commandLine(
val packArgs = mutableListOf(
packer.absolutePath,
"--app-image",
appDir.absolutePath,
"--version",
desktopVersionName,
"--registration-id",
registrationId,
"--setup-out",
windowsSetupOutput.get().asFile.absolutePath,
setupOutput.get().asFile.absolutePath,
"--setup-bin",
setupBin.absolutePath,
"--helper-bin",
@@ -700,15 +972,141 @@ fun Exec.configureWindowsPackTask() {
"--launcher-bin",
launcherBin.absolutePath,
)
var packed = false
if (prebuiltX64.isDirectory) {
packArgs += listOf("--app-image-x64", prebuiltX64.absolutePath)
packed = true
} else if (!prebuiltOnly && nativeAppDir != null && nativeAppDir.isDirectory && !isWindowsArm64Host()) {
packArgs += listOf("--app-image-x64", nativeAppDir.absolutePath)
packed = true
}
if (prebuiltArm64.isDirectory) {
packArgs += listOf("--app-image-arm64", prebuiltArm64.absolutePath)
packed = true
} else if (!prebuiltOnly && nativeAppDir != null && nativeAppDir.isDirectory && isWindowsArm64Host()) {
packArgs += listOf("--app-image-arm64", nativeAppDir.absolutePath)
packed = true
}
check(packed) {
if (prebuiltOnly) {
"packUniversalWindows needs both prebuilt/windows-x64 and prebuilt/windows-arm64 app images"
} else {
"No Windows app-image payload for setup EXE"
}
}
commandLine(packArgs)
}
}
tasks.register<Exec>("packUniversalWindows") {
group = "compose desktop"
description = "Pack a universal Windows setup EXE from prebuilt x64 + arm64 app images."
onlyIf { runningOnWindows }
dependsOn(buildWindowsSetupRust)
configureWindowsPackTask(
appImageDir = null,
setupOutput = windowsUniversalSetupOutput,
prebuiltOnly = true,
)
}
tasks.register<Exec>("packSetupOnly") {
group = "compose desktop"
description = "Repack setup EXE from existing app-image + Rust (skips ProGuard)."
onlyIf { runningOnWindows }
dependsOn(buildWindowsSetupRust)
configureWindowsPackTask()
configureWindowsPackTask(
appImageDir = releaseAppImageDir,
setupOutput = windowsSetupOutput,
)
}
tasks.register<Exec>("packageBetaWindows") {
group = "compose desktop"
description = "Build debug Windows app-image (no ProGuard) and beta setup EXE."
onlyIf { runningOnWindows }
dependsOn("createDistributable", buildWindowsSetupRust, patchWindowsJpackageIcon)
configureWindowsPackTask(
appImageDir = debugAppImageDir,
setupOutput = windowsBetaSetupOutput,
registrationId = "FromChat Beta",
)
doFirst {
check(project.findProperty("betaDesktop") != null) {
"Run with -PbetaDesktop (e.g. gradlew :app:desktop:packageBetaWindows -PbetaDesktop)"
}
if (isWindowsArm64Host()) {
val packagingJdk = File(resolvePackagingJdkHome(), "bin/java.exe")
val arch = jdkReportsArch(packagingJdk)
check(arch == "aarch64") {
"packageBetaWindows on Windows ARM64 requires ARM64 packaging JDK (os.arch=$arch): $packagingJdk"
}
val gradleJava = File(System.getProperty("java.home"), "bin/java.exe")
val gradleArch = jdkReportsArch(gradleJava)
check(gradleArch == "aarch64") {
"Gradle must run on ARM64 JDK on Windows ARM64 (os.arch=$gradleArch). " +
"Run scripts\\ensure-windows-arm64-jdk.cmd before gradlew."
}
}
val outDir = desktopDistDir.get().asFile
if (outDir.isDirectory) {
outDir.listFiles()?.forEach { file ->
if (
file.isFile &&
file.name.startsWith("FromChat-Setup-") &&
file.name.endsWith(".exe", ignoreCase = true) &&
file.name != windowsBetaSetupOutput.get().asFile.name
) {
file.delete()
}
}
}
if (isWindowsArm64Host()) {
val appDir = debugAppImageDir.get().asFile.resolve("app")
syncWindowsArm64SkikoNatives(appDir, configurations.runtimeClasspath.get().files)
}
}
}
afterEvaluate {
tasks.matching { it.name == "createRuntimeImage" || it.name == "createReleaseRuntimeImage" }.configureEach {
if (!isWindowsArm64Host()) return@configureEach
inputs.property("packagingJdkHome", resolvePackagingJdkHome())
doLast {
val runtimeDir = when (name) {
"createReleaseRuntimeImage" -> layout.buildDirectory.dir("compose/tmp/main-release/runtime")
else -> layout.buildDirectory.dir("compose/tmp/main/runtime")
}
val jli = runtimeDir.get().asFile.resolve("bin/jli.dll")
check(jli.isFile) { "Missing runtime image JRE: $jli" }
assertWindowsBinaryArch(jli, "createRuntimeImage output")
}
}
tasks.matching { it.name == "createDistributable" || it.name == "createReleaseDistributable" }.configureEach {
doLast {
val appImageRoot = when (name) {
"createReleaseDistributable" -> releaseAppImageDir.get().asFile
else -> debugAppImageDir.get().asFile
}
assertWindowsAppImageArch(appImageRoot)
if (!isWindowsArm64Host()) return@doLast
val appDir = appImageRoot.resolve("app")
syncWindowsArm64SkikoNatives(appDir, configurations.runtimeClasspath.get().files)
val natives = appDir.listFiles()?.map { it.name }.orEmpty()
check(natives.none { it.contains("windows-x64", ignoreCase = true) }) {
"App image still contains x64 Skiko on Windows ARM64: $appDir"
}
check(natives.any { it.contains("windows-arm64", ignoreCase = true) }) {
"App image is missing ARM64 Skiko runtime: $appDir"
}
check(natives.any { it.equals("libEGL.dll", ignoreCase = true) }) {
"App image is missing ANGLE libEGL.dll: $appDir"
}
check(natives.any { it.equals("libGLESv2.dll", ignoreCase = true) }) {
"App image is missing ANGLE libGLESv2.dll: $appDir"
}
}
}
}
tasks.register<Exec>("packageReleaseWindows") {
@@ -716,9 +1114,12 @@ tasks.register<Exec>("packageReleaseWindows") {
description = "Build release Windows app-image, then setup EXE."
onlyIf { runningOnWindows }
if (runningOnWindows) {
dependsOn("createReleaseDistributable", buildWindowsSetupRust)
dependsOn("createReleaseDistributable", buildWindowsSetupRust, patchWindowsJpackageReleaseIcon)
}
configureWindowsPackTask()
configureWindowsPackTask(
appImageDir = releaseAppImageDir,
setupOutput = windowsSetupOutput,
)
}
tasks.register("packageReleaseDesktop") {
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -0,0 +1,227 @@
package ru.fromchat.desktop
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.rememberWindowState
import java.awt.AWTEvent
import java.awt.SystemTray
import java.awt.Toolkit
import java.awt.TrayIcon
import java.awt.event.AWTEventListener
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.image.BufferedImage
import javax.swing.SwingUtilities
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.getColorScheme
import ru.fromchat.ui.isInsideWindow
@Composable
internal fun DesktopTrayHost(
trayImage: BufferedImage,
tooltip: String,
statusLabel: String,
showLabel: String,
aboutLabel: String,
quitLabel: String,
darkTheme: Boolean,
onShow: () -> Unit,
onAbout: () -> Unit,
onQuit: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
var menuPosition by remember { mutableStateOf(WindowPosition(0.dp, 0.dp)) }
val closeMenu = rememberUpdatedState { menuOpen = false }
val onShowState = rememberUpdatedState(onShow)
DisposableEffect(trayImage, tooltip) {
if (!SystemTray.isSupported()) {
onDispose {}
} else {
val trayIcon = TrayIcon(trayImage, tooltip)
trayIcon.isImageAutoSize = true
fun openMenu(event: MouseEvent) {
val screen = event.locationOnScreen
SwingUtilities.invokeLater {
menuPosition = WindowPosition(
x = (screen.x - 220).coerceAtLeast(8).dp,
y = (screen.y - 180).coerceAtLeast(8).dp,
)
menuOpen = true
}
}
val listener = object : MouseAdapter() {
override fun mouseClicked(event: MouseEvent) {
if (MouseEvent.BUTTON1 == event.button && event.clickCount == 1) {
SwingUtilities.invokeLater { onShowState.value() }
}
}
override fun mousePressed(event: MouseEvent) {
if (event.isPopupTrigger) {
event.consume()
}
}
override fun mouseReleased(event: MouseEvent) {
if (event.isPopupTrigger) {
openMenu(event)
}
}
}
trayIcon.addMouseListener(listener)
val tray = SystemTray.getSystemTray()
runCatching { tray.add(trayIcon) }
onDispose {
trayIcon.removeMouseListener(listener)
runCatching { tray.remove(trayIcon) }
}
}
}
if (menuOpen) {
val windowState = rememberWindowState(
position = menuPosition,
width = 240.dp,
height = 220.dp,
)
Window(
onCloseRequest = { menuOpen = false },
state = windowState,
title = "",
undecorated = true,
transparent = false,
resizable = false,
alwaysOnTop = true,
focusable = true,
) {
MaterialTheme(colorScheme = getColorScheme(darkTheme, dynamicColor = false)) {
DisposableEffect(window) {
val toolkit = Toolkit.getDefaultToolkit()
var armed = false
val armTimer = javax.swing.Timer(200) { armed = true }.apply {
isRepeats = false
start()
}
val dismissListener = AWTEventListener { event ->
when (event.id) {
MouseEvent.MOUSE_RELEASED -> armed = true
MouseEvent.MOUSE_PRESSED -> {
if (!armed) return@AWTEventListener
val mouse = event as MouseEvent
if (mouse.button != MouseEvent.BUTTON1) return@AWTEventListener
if (mouse.isInsideWindow(window)) return@AWTEventListener
SwingUtilities.invokeLater { closeMenu.value() }
}
}
}
toolkit.addAWTEventListener(dismissListener, AWTEvent.MOUSE_EVENT_MASK)
onDispose {
armTimer.stop()
toolkit.removeAWTEventListener(dismissListener)
}
}
Column(
modifier = Modifier
.shadow(12.dp, RoundedCornerShape(12.dp))
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(vertical = 6.dp)
.widthIn(min = 220.dp),
) {
DesktopTrayMenuRow(
text = statusLabel,
enabled = false,
onClick = {},
)
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f),
)
DesktopTrayMenuRow(text = showLabel, onClick = {
menuOpen = false
onShow()
})
DesktopTrayMenuRow(text = aboutLabel, onClick = {
menuOpen = false
onAbout()
})
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f),
)
DesktopTrayMenuRow(text = quitLabel, onClick = {
menuOpen = false
onQuit()
})
}
}
}
}
}
@Composable
private fun DesktopTrayMenuRow(
text: String,
enabled: Boolean = true,
onClick: () -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
val hovered by interactionSource.collectIsHoveredAsState()
val textColor = when {
!enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f)
else -> MaterialTheme.colorScheme.onSurface
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(
if (enabled && hovered) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
} else {
androidx.compose.ui.graphics.Color.Transparent
},
)
.hoverable(interactionSource, enabled = enabled)
.clickable(
interactionSource = interactionSource,
indication = null,
enabled = enabled,
onClick = onClick,
)
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = textColor,
)
}
}
@@ -0,0 +1,23 @@
package ru.fromchat.desktop
import javax.swing.JRootPane
internal fun isMacOs(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("mac")
internal fun isWindowsOs(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("win")
internal fun isWindowsArm64(): Boolean {
if (!isWindowsOs()) return false
val arch = System.getProperty("os.arch").orEmpty().lowercase()
return arch.contains("aarch64") || arch.contains("arm64")
}
/** Enables drawing under the system title bar on macOS. */
internal fun applyDesktopEdgeToEdgeChrome(rootPane: JRootPane) {
if (!isMacOs()) return
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
}
@@ -1,8 +1,10 @@
package ru.fromchat.desktop
import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@@ -14,7 +16,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.zIndex
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toComposeImageBitmap
@@ -84,6 +88,7 @@ import ru.fromchat.notifications.MessageNotificationCoordinator
import ru.fromchat.ui.App
import ru.fromchat.ui.LocalExtraStatusBarTop
import ru.fromchat.ui.Theme
import ru.fromchat.ui.getColorScheme
import java.awt.Desktop
import java.awt.Image
import java.awt.KeyboardFocusManager
@@ -280,6 +285,19 @@ fun main(args: Array<String>) {
}
if (!DesktopSingleInstance.acquireOrForward(args)) return
DesktopProtocolRegistration.register()
if (isWindowsOs()) {
if (isWindowsArm64()) {
System.setProperty("skiko.rendering.angle.enabled", "true")
} else {
System.setProperty("skiko.renderApi", "OPENGL")
}
if (AppBuildInfo.isDebug && System.getProperty("fromchat.app.data.name").isNullOrBlank()) {
System.setProperty("fromchat.app.data.name", "FromChatBeta")
}
setWindowsDesktopAppUserModelId(
if (AppBuildInfo.isDebug) "FromChat Beta" else "FromChat",
)
}
// Close-to-tray keeps the process + tray; no SMAppService / Login Items registration.
args.filter { DesktopDeepLinkBus.isFromChatUri(it) }
.forEach { DesktopDeepLinkBus.handleUri(it) }
@@ -307,13 +325,17 @@ fun main(args: Array<String>) {
var contentReady by remember { mutableStateOf(false) }
var windowVisible by remember { mutableStateOf(true) }
val trayState = rememberTrayState()
// BufferedImage.toPainter() keeps the AWT pixels (avoids blank BitmapPainter round-trip).
val trayIcon = remember { loadAppIconPainter(tray = true) }
val mac = remember { isMacOs() }
val windows = remember { isWindowsOs() }
val useTemplateTrayIcon = mac
val trayIconImage = remember { loadAppIconBufferedImage(tray = useTemplateTrayIcon) }
val trayIcon = remember(trayIconImage) {
trayIconImage?.toPainter() ?: loadAppIconPainter(tray = useTemplateTrayIcon)
}
val windowIcon = remember {
dockIconImage?.toPainter() ?: loadAppIconPainter(tray = false)
}
val traySupported = remember { java.awt.SystemTray.isSupported() }
val mac = remember { isMacOs() }
val appName = stringResource(
if (AppBuildInfo.isDebug) Res.string.app_name_beta else Res.string.app_name,
)
@@ -370,10 +392,15 @@ fun main(args: Array<String>) {
"subtitleLen=${payload.subtitle.length}",
)
if (!native) {
Logger.i("DesktopNotifier", "sink fallback trayState.sendNotification")
trayState.sendNotification(
Notification(title = payload.title, message = payload.displayBody()),
)
if (mac) {
Logger.i("DesktopNotifier", "sink fallback trayState.sendNotification")
trayState.sendNotification(
Notification(title = payload.title, message = payload.displayBody()),
)
} else {
Logger.i("DesktopNotifier", "sink fallback AWT balloon")
DesktopNotifier.showAwtFallback(payload.title, payload.displayBody())
}
}
}
onDispose {
@@ -471,20 +498,40 @@ fun main(args: Array<String>) {
}
if (traySupported) {
Tray(
icon = trayIcon,
state = trayState,
tooltip = appName,
onAction = { showMainWindow() },
menu = {
Item(wsStatusLabel, enabled = false) {}
Separator()
Item(trayShow) { showMainWindow() }
Item(aboutApp) { openAbout() }
Separator()
Item(quit) { exitApplication() }
},
)
val darkTheme = when (runCatching { Settings.theme }.getOrDefault(Theme.AsSystem)) {
Theme.Dark -> true
Theme.Light -> false
Theme.AsSystem -> isSystemAppearanceDark()
}
if (mac) {
Tray(
icon = trayIcon,
state = trayState,
tooltip = appName,
onAction = { showMainWindow() },
menu = {
Item(wsStatusLabel, enabled = false) {}
Separator()
Item(trayShow) { showMainWindow() }
Item(aboutApp) { openAbout() }
Separator()
Item(quit) { exitApplication() }
},
)
} else if (trayIconImage != null) {
DesktopTrayHost(
trayImage = trayIconImage,
tooltip = appName,
statusLabel = wsStatusLabel,
showLabel = trayShow,
aboutLabel = aboutApp,
quitLabel = quit,
darkTheme = darkTheme,
onShow = { showMainWindow() },
onAbout = { openAbout() },
onQuit = { exitApplication() },
)
}
}
Window(
@@ -493,6 +540,7 @@ fun main(args: Array<String>) {
state = windowState,
visible = contentReady && (windowVisible || !traySupported),
icon = windowIcon,
undecorated = windows,
onPreviewKeyEvent = { event ->
// macOS: MenuBar KeyShortcuts handle these. Win/Linux: no menu bar.
if (mac || event.type != KeyEventType.KeyDown || !event.isCtrlPressed) {
@@ -541,9 +589,16 @@ fun main(args: Array<String>) {
windowChrome.toAwtColor().also {
window.background = it
window.contentPane.background = it
if (windows) {
updateWindowsNativeCaptionBackground(window, it)
}
}
enableEdgeToEdgeTitleBar(window.rootPane)
if (windows) {
installWindowsNativeCaptionChrome(window)
applyWindowsRoundedCorners(window)
}
applyDesktopEdgeToEdgeChrome(window.rootPane)
dockIconImage?.let { image ->
window.iconImages = listOf(image)
applyDockIcon(image)
@@ -622,7 +677,11 @@ fun main(args: Array<String>) {
}
CompositionLocalProvider(
LocalExtraStatusBarTop provides if (mac) 28.dp else 0.dp,
LocalExtraStatusBarTop provides when {
mac -> 28.dp
windows -> WindowsTitleBarHeight
else -> 0.dp
},
) {
Box(
Modifier
@@ -630,6 +689,26 @@ fun main(args: Array<String>) {
.background(windowChrome),
) {
App(onContentReady = { contentReady = true })
if (windows) {
val darkTheme = when (runCatching { Settings.theme }.getOrDefault(Theme.AsSystem)) {
Theme.Dark -> true
Theme.Light -> false
Theme.AsSystem -> isSystemAppearanceDark()
}
MaterialTheme(colorScheme = getColorScheme(darkTheme, dynamicColor = false)) {
WindowsDesktopTitleBar(
title = appName,
windowIcon = windowIcon,
window = window,
windowState = windowState,
onCloseRequest = { requestCloseToBackgroundOrQuit() },
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.zIndex(10_000f),
)
}
}
}
}
}
@@ -748,12 +827,6 @@ private fun performAwtEditAction(actionName: String) {
action.actionPerformed(ActionEvent(focusOwner, ActionEvent.ACTION_PERFORMED, actionName))
}
private fun isMacOs(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("mac")
private fun isWindowsOs(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("win")
private fun isLinuxOs(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("linux")
@@ -792,13 +865,6 @@ private fun isSystemAppearanceDark() = runCatching {
} else false
}.getOrDefault(false)
private fun enableEdgeToEdgeTitleBar(rootPane: JRootPane) {
if (!isMacOs()) return
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
}
private fun applyDockIcon(image: BufferedImage?) {
if (image == null) {
Logger.w("DesktopIcon", "applyDockIcon skipped — image is null")
@@ -0,0 +1,14 @@
package ru.fromchat.desktop
import com.sun.jna.WString
import com.sun.jna.platform.win32.Shell32
/** Taskbar / jump-list identity for the desktop app (must run before first window). */
internal fun setWindowsDesktopAppUserModelId(registrationId: String) {
if (!isWindowsOs()) return
val id = when (registrationId) {
"FromChat Beta" -> "denis0001-dev.FromChat.Beta"
else -> "denis0001-dev.FromChat"
}
Shell32.INSTANCE.SetCurrentProcessExplicitAppUserModelID(WString(id))
}
@@ -0,0 +1,238 @@
package ru.fromchat.desktop
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.window.WindowDraggableArea
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowScope
import androidx.compose.ui.window.WindowState
import java.awt.Window
import ru.fromchat.ui.components.Text
/** Height of the custom Windows title bar; keep in sync with [ru.fromchat.ui.LocalExtraStatusBarTop]. */
val WindowsTitleBarHeight = 32.dp
private val TitleBarHoverAlpha = 0.28f
private val TitleBarPressAlpha = 0.36f
private val TitleBarCloseHoverAlpha = 1f
@Composable
fun WindowScope.WindowsDesktopTitleBar(
title: String,
windowIcon: Painter,
window: Window,
windowState: WindowState,
onCloseRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
window.syncWindowsPlacementFromNative(windowState)
val windowActive = rememberWindowsFrameActive(window)
val isMaximized = windowState.placement == WindowPlacement.Maximized
val scheme = MaterialTheme.colorScheme
val titleColor =
if (windowActive) {
Color.White
} else {
Color.White.copy(alpha = 0.63f)
}
Row(
modifier
.fillMaxWidth()
.height(WindowsTitleBarHeight),
verticalAlignment = Alignment.CenterVertically,
) {
WindowDraggableArea(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
) {
Row(
Modifier
.fillMaxHeight()
.padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = windowIcon,
contentDescription = null,
modifier = Modifier.size(16.dp),
alpha = if (windowActive) 1f else 0.63f,
)
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = titleColor,
modifier = Modifier.padding(start = 8.dp),
)
}
}
TitleBarWindowButton(
onClick = { window.windowsMinimize() },
windowActive = windowActive,
isClose = false,
) {
TitleBarRemoveIcon(tint = it)
}
TitleBarWindowButton(
onClick = { window.windowsToggleMaximize(windowState) },
windowActive = windowActive,
isClose = false,
) {
if (isMaximized) {
TitleBarRestoreIcon(tint = it)
} else {
TitleBarMaximizeIcon(tint = it)
}
}
TitleBarWindowButton(
onClick = onCloseRequest,
windowActive = windowActive,
isClose = true,
) {
TitleBarCloseIcon(tint = it)
}
}
}
@Composable
private fun TitleBarWindowButton(
onClick: () -> Unit,
windowActive: Boolean,
isClose: Boolean,
icon: @Composable (Color) -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
val hovered by interactionSource.collectIsHoveredAsState()
val pressed by interactionSource.collectIsPressedAsState()
val scheme = MaterialTheme.colorScheme
val iconTint = when {
isClose && (hovered || pressed) -> scheme.onError
windowActive -> Color.White
else -> Color.White.copy(alpha = 0.63f)
}
val hoverAlpha = when {
isClose && (hovered || pressed) -> TitleBarCloseHoverAlpha
pressed -> TitleBarPressAlpha
hovered -> TitleBarHoverAlpha
else -> 0f
}
val hoverColor =
if (isClose && (hovered || pressed)) {
scheme.error
} else {
scheme.onSurface
}
Box(
modifier = Modifier
.width(46.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(0.dp))
.hoverable(interactionSource)
.background(hoverColor.copy(alpha = hoverAlpha))
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
icon(iconTint)
}
}
@Composable
private fun TitleBarRemoveIcon(tint: Color) {
Canvas(Modifier.size(16.dp)) {
val y = size.height / 2f
val pad = size.width * 0.22f
drawLine(
color = tint,
start = Offset(pad, y),
end = Offset(size.width - pad, y),
strokeWidth = size.height * 0.06f,
)
}
}
@Composable
private fun TitleBarCloseIcon(tint: Color) {
Canvas(Modifier.size(16.dp)) {
val pad = size.width * 0.28f
val stroke = size.height * 0.06f
drawLine(color = tint, start = Offset(pad, pad), end = Offset(size.width - pad, size.height - pad), strokeWidth = stroke)
drawLine(color = tint, start = Offset(size.width - pad, pad), end = Offset(pad, size.height - pad), strokeWidth = stroke)
}
}
/** Material `select_window_2` — rounded maximize tile. */
@Composable
private fun TitleBarMaximizeIcon(tint: Color) {
Canvas(Modifier.size(16.dp)) {
val inset = size.width * 0.24f
val stroke = size.height * 0.06f
val corner = size.width * 0.12f
drawRoundRect(
color = tint,
topLeft = Offset(inset, inset),
size = Size(size.width - inset * 2f, size.height - inset * 2f),
cornerRadius = CornerRadius(corner, corner),
style = Stroke(width = stroke),
)
}
}
/** Restored-from-maximize: overlapping rounded tiles. */
@Composable
private fun TitleBarRestoreIcon(tint: Color) {
Canvas(Modifier.size(16.dp)) {
val stroke = size.height * 0.06f
val corner = size.width * 0.1f
val backSize = size.width * 0.46f
val frontSize = size.width * 0.52f
drawRoundRect(
color = tint,
topLeft = Offset(size.width * 0.28f, size.height * 0.16f),
size = Size(backSize, backSize),
cornerRadius = CornerRadius(corner, corner),
style = Stroke(width = stroke),
)
drawRoundRect(
color = tint,
topLeft = Offset(size.width * 0.2f, size.height * 0.28f),
size = Size(frontSize, frontSize),
cornerRadius = CornerRadius(corner, corner),
style = Stroke(width = stroke),
)
}
}
@@ -0,0 +1,47 @@
package ru.fromchat.desktop
import com.sun.jna.Library
import com.sun.jna.Memory
import com.sun.jna.Native
import com.sun.jna.platform.win32.WinDef.HWND
import java.awt.Window
private const val DWMWA_TRANSITIONS_FORCEDISABLED = 3
private const val DWMWA_WINDOW_CORNER_PREFERENCE = 33
private const val DWMWCP_ROUND = 2
private interface DwmapiLib : Library {
fun DwmSetWindowAttribute(
hwnd: HWND,
dwAttribute: Int,
pvAttribute: Memory,
cbAttribute: Int,
): Int
companion object {
val INSTANCE: DwmapiLib = Native.load("dwmapi", DwmapiLib::class.java)
}
}
/** Opt in to Windows 11 rounded window corners for undecorated custom chrome. */
internal fun applyWindowsRoundedCorners(window: Window) {
if (!isWindowsOs()) return
val hwnd = window.windowsHwnd()
if (hwnd.pointer == null) return
setDwmInt(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND)
setDwmInt(hwnd, DWMWA_TRANSITIONS_FORCEDISABLED, 0)
}
internal fun setDwmTransitionsForcedDisabled(hwnd: HWND, disabled: Boolean) {
setDwmInt(hwnd, DWMWA_TRANSITIONS_FORCEDISABLED, if (disabled) 1 else 0)
}
private fun setDwmInt(hwnd: HWND, attribute: Int, value: Int) {
val memory = Memory(4)
memory.setInt(0, value)
DwmapiLib.INSTANCE.DwmSetWindowAttribute(hwnd, attribute, memory, 4)
}
internal fun applyWindowsRoundedCorners(composeWindow: androidx.compose.ui.awt.ComposeWindow) {
applyWindowsRoundedCorners(composeWindow as Window)
}
@@ -0,0 +1,87 @@
package ru.fromchat.desktop
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowState
import com.sun.jna.platform.win32.User32
import com.sun.jna.platform.win32.WinDef.LPARAM
import com.sun.jna.platform.win32.WinDef.WPARAM
import com.sun.jna.platform.win32.WinUser
import java.awt.Frame
import java.awt.Window
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import java.awt.event.WindowStateListener
/** Tracks whether the AWT frame is the active (foreground) window. */
@Composable
internal fun rememberWindowsFrameActive(window: Window): Boolean {
var active by remember(window) {
mutableStateOf(window.isActive)
}
DisposableEffect(window) {
val listener = object : WindowAdapter() {
override fun windowActivated(event: WindowEvent?) {
active = true
}
override fun windowDeactivated(event: WindowEvent?) {
active = false
}
}
window.addWindowListener(listener)
active = window.isActive
onDispose { window.removeWindowListener(listener) }
}
return active
}
internal fun Window.windowsMinimize() {
User32.INSTANCE.SendMessage(
windowsHwnd(),
WinUser.WM_SYSCOMMAND,
WPARAM(SC_MINIMIZE),
LPARAM(0),
)
}
internal fun Window.windowsToggleMaximize(windowState: WindowState) {
val hwnd = windowsHwnd()
if (hwnd.isNativeZoomed() || windowState.placement == WindowPlacement.Maximized) {
User32.INSTANCE.SendMessage(hwnd, WinUser.WM_SYSCOMMAND, WPARAM(SC_RESTORE), LPARAM(0))
windowState.placement = WindowPlacement.Floating
} else {
User32.INSTANCE.SendMessage(hwnd, WinUser.WM_SYSCOMMAND, WPARAM(SC_MAXIMIZE), LPARAM(0))
windowState.placement = WindowPlacement.Maximized
}
}
@Composable
internal fun Window.syncWindowsPlacementFromNative(windowState: WindowState) {
DisposableEffect(this, windowState) {
val frame = this@syncWindowsPlacementFromNative as? Frame
if (frame == null) {
onDispose {}
} else {
val listener = WindowStateListener { event ->
windowState.placement =
if (event.newState and Frame.MAXIMIZED_BOTH != 0) {
WindowPlacement.Maximized
} else {
WindowPlacement.Floating
}
}
frame.addWindowStateListener(listener)
onDispose { frame.removeWindowStateListener(listener) }
}
}
}
private const val SC_MINIMIZE = 0xF020L
private const val SC_MAXIMIZE = 0xF030L
private const val SC_RESTORE = 0xF120L
@@ -0,0 +1,219 @@
package ru.fromchat.desktop
import androidx.compose.ui.awt.ComposeWindow
import com.sun.jna.Native
import com.sun.jna.NativeLibrary
import com.sun.jna.Pointer
import com.sun.jna.platform.win32.BaseTSD.LONG_PTR
import com.sun.jna.platform.win32.User32
import com.sun.jna.platform.win32.WinDef.HWND
import com.sun.jna.platform.win32.WinDef.LPARAM
import com.sun.jna.platform.win32.WinDef.LRESULT
import com.sun.jna.platform.win32.WinDef.RECT
import com.sun.jna.platform.win32.WinDef.WPARAM
import com.sun.jna.platform.win32.WinUser
import com.sun.jna.platform.win32.WinUser.WindowProc
import com.sun.jna.win32.W32APIOptions
import java.awt.Color
import java.awt.Window
import javax.swing.JFrame
private const val ChromeKey = "fromchat.windowsNativeChrome"
private const val ChromeBackgroundKey = "fromchat.windowsNativeChromeBackground"
private const val WM_NCCALCSIZE = 0x0083
private const val WM_ERASEBKGND = 0x0014
private const val GWLP_WNDPROC = -4
/**
* Makes the undecorated Compose window look borderless while remaining an overlapped
* Win32 window, so maximize/minimize use DWM animations and maximize to the work area.
*
* [undecorated][androidx.compose.ui.window.Window] creates a `WS_POPUP` window. Popup
* maximize is fullscreen (no animation). Restoring `WS_CAPTION` and handling
* `WM_NCCALCSIZE` is the same approach as FlatLaf native decorations and
* rossy/borderless-window.
*/
internal fun installWindowsNativeCaptionChrome(window: Window) {
if (!isWindowsOs()) return
val frame = window as? JFrame ?: return
if (frame.rootPane.getClientProperty(ChromeKey) != null) return
val hwnd = window.windowsHwnd()
if (hwnd.pointer == null) return
val background = frame.background ?: Color(0x1C, 0x1B, 0x1F)
frame.rootPane.putClientProperty(ChromeBackgroundKey, background)
frame.rootPane.putClientProperty(ChromeKey, WindowsCaptionWndProc(hwnd, background))
}
internal fun updateWindowsNativeCaptionBackground(window: Window, background: Color) {
if (!isWindowsOs()) return
val frame = window as? JFrame ?: return
frame.rootPane.putClientProperty(ChromeBackgroundKey, background)
(frame.rootPane.getClientProperty(ChromeKey) as? WindowsCaptionWndProc)?.background = background
}
internal fun Window.windowsHwnd(): HWND {
val handle = (this as? ComposeWindow)?.windowHandle ?: 0L
return if (handle != 0L) {
HWND(Pointer(handle))
} else {
HWND(Native.getComponentPointer(this))
}
}
internal fun HWND.isNativeZoomed(): Boolean =
User32.INSTANCE.GetWindowLong(this, WinUser.GWL_STYLE) and WS_MAXIMIZE != 0
private const val WS_MAXIMIZE = 0x01000000
@Suppress("FunctionName")
private interface User32Ex : User32 {
fun SetWindowLong(hWnd: HWND, nIndex: Int, wndProc: WindowProc): LONG_PTR
fun SetWindowLongPtr(hWnd: HWND, nIndex: Int, wndProc: WindowProc): LONG_PTR
fun CallWindowProc(proc: LONG_PTR, hWnd: HWND, uMsg: Int, wParam: WPARAM, lParam: LPARAM): LRESULT
}
private class WindowsCaptionWndProc(
private val hwnd: HWND,
@Volatile var background: Color,
) : WindowProc {
private val user32 = runCatching {
Native.load("user32", User32Ex::class.java, W32APIOptions.DEFAULT_OPTIONS)
}.getOrNull()
private val defaultWndProc = when {
user32 == null -> LONG_PTR(-1)
is64Bit() -> user32.SetWindowLongPtr(hwnd, GWLP_WNDPROC, this)
else -> user32.SetWindowLong(hwnd, GWLP_WNDPROC, this)
}
init {
val style = User32.INSTANCE.GetWindowLong(hwnd, WinUser.GWL_STYLE)
User32.INSTANCE.SetWindowLong(
hwnd,
WinUser.GWL_STYLE,
style and WinUser.WS_POPUP.inv() or
WinUser.WS_CAPTION or
WinUser.WS_THICKFRAME or
WinUser.WS_SYSMENU or
WinUser.WS_MINIMIZEBOX or
WinUser.WS_MAXIMIZEBOX,
)
extendFrameIntoClientArea(hwnd)
setDwmTransitionsForcedDisabled(hwnd, disabled = false)
User32.INSTANCE.SetWindowPos(
hwnd,
null,
0,
0,
0,
0,
WinUser.SWP_NOMOVE or
WinUser.SWP_NOSIZE or
WinUser.SWP_NOZORDER or
WinUser.SWP_NOACTIVATE or
WinUser.SWP_FRAMECHANGED,
)
}
override fun callback(hWnd: HWND, uMsg: Int, wParam: WPARAM, lParam: LPARAM): LRESULT =
when (uMsg) {
WM_ERASEBKGND -> {
eraseClientBackground(wParam)
LRESULT(1)
}
WM_NCCALCSIZE -> handleNcCalcSize(hWnd, wParam, lParam)
else -> callDefault(hWnd, uMsg, wParam, lParam)
}
private fun eraseClientBackground(wParam: WPARAM) {
runCatching {
val gdi = NativeLibrary.getInstance("gdi32")
val hdc = Pointer(wParam.toLong())
val rect = RECT()
if (!User32.INSTANCE.GetClientRect(hwnd, rect)) return
val brush = gdi.getFunction("CreateSolidBrush")
.invoke(arrayOf(background.toColorRef())) as? Pointer ?: return
gdi.getFunction("FillRect").invoke(arrayOf(hdc, rect, brush))
gdi.getFunction("DeleteObject").invoke(arrayOf(brush))
}
}
private fun handleNcCalcSize(hWnd: HWND, wParam: WPARAM, lParam: LPARAM): LRESULT {
if (wParam.toLong() == 0L) {
return callDefault(hWnd, WM_NCCALCSIZE, wParam, lParam)
}
val pointer = Pointer(lParam.toLong())
val before = RECT().apply {
left = pointer.getInt(0)
top = pointer.getInt(4)
right = pointer.getInt(8)
bottom = pointer.getInt(12)
}
callDefault(hWnd, WM_NCCALCSIZE, wParam, lParam)
if (hWnd.isNativeZoomed()) {
applyMaximizedWorkArea(lParam)
} else {
pointer.setInt(0, before.left)
pointer.setInt(4, before.top)
pointer.setInt(8, before.right)
pointer.setInt(12, before.bottom)
}
return LRESULT(0)
}
private fun callDefault(hWnd: HWND, uMsg: Int, wParam: WPARAM, lParam: LPARAM): LRESULT {
val lib = user32 ?: return LRESULT(0)
return if (defaultWndProc.toLong() == -1L) {
User32.INSTANCE.DefWindowProc(hWnd, uMsg, wParam, lParam)
} else {
lib.CallWindowProc(defaultWndProc, hWnd, uMsg, wParam, lParam)
}
}
}
private fun applyMaximizedWorkArea(lParam: LPARAM) {
val pointer = Pointer(lParam.toLong())
val proposed = RECT().apply {
left = pointer.getInt(0)
top = pointer.getInt(4)
right = pointer.getInt(8)
bottom = pointer.getInt(12)
}
val monitor = User32.INSTANCE.MonitorFromRect(proposed, WinUser.MONITOR_DEFAULTTONEAREST)
?: return
val info = WinUser.MONITORINFO()
User32.INSTANCE.GetMonitorInfo(monitor, info)
val work = info.rcWork
if (work.right <= work.left || work.bottom <= work.top) return
pointer.setInt(0, work.left)
pointer.setInt(4, work.top)
pointer.setInt(8, work.right)
pointer.setInt(12, work.bottom)
}
private fun extendFrameIntoClientArea(hwnd: HWND) {
runCatching { NativeLibrary.getInstance("dwmapi") }
.getOrNull()
?.runCatching { getFunction("DwmExtendFrameIntoClientArea") }
?.getOrNull()
?.invoke(arrayOf(hwnd, DwmMargins()))
}
private fun is64Bit(): Boolean =
System.getProperty("sun.arch.data.model") == "64"
@com.sun.jna.Structure.FieldOrder(
"cxLeftWidth",
"cxRightWidth",
"cyTopHeight",
"cyBottomHeight",
)
internal class DwmMargins(
@JvmField var cxLeftWidth: Int = 0,
@JvmField var cxRightWidth: Int = 0,
@JvmField var cyTopHeight: Int = 0,
@JvmField var cyBottomHeight: Int = 0,
) : com.sun.jna.Structure(), com.sun.jna.Structure.ByReference
private fun Color.toColorRef(): Int =
(blue and 0xFF shl 16) or (green and 0xFF shl 8) or (red and 0xFF)
+2 -1
View File
@@ -6,6 +6,7 @@ members = [
"helper",
"portable-launcher",
"pack",
"icon-patch",
]
[workspace.package]
@@ -14,7 +15,7 @@ edition = "2021"
license = "MIT"
[workspace.dependencies]
fromchat-setup-common = { path = "common" }
fromchat-installer-common = { path = "common" }
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+2 -1
View File
@@ -1,5 +1,5 @@
[package]
name = "fromchat-setup-common"
name = "fromchat-installer-common"
version.workspace = true
edition.workspace = true
@@ -11,6 +11,7 @@ zstd.workspace = true
tar.workspace = true
image = { version = "0.25", default-features = false, features = ["png"] }
ico = "0.4"
editpe = { version = "0.2", default-features = false, features = ["std", "images"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
@@ -0,0 +1,58 @@
use crate::BRANDING_PNG;
use anyhow::{Context, Result};
use editpe::{Image, ResourceEntryName};
use std::path::Path;
/// `RT_ICON` / `RT_GROUP_ICON` — Windows uses the first numeric group as the Task Manager icon.
const RT_ICON: u32 = 3;
const RT_GROUP_ICON: u32 = 14;
/// Embeds the branded app icon into a jpackage `FromChat.exe` (Task Manager / Alt+Tab).
///
/// jpackage leaves the default Java coffee-cup as group icon ID 1. `set_main_icon` only
/// adds a named `MAINICON` group and keeps that Java icon, so Task Manager still shows it.
/// Strip every icon resource first, then write ours as the only group.
pub fn patch_jpackage_exe_icon(exe_path: &Path) -> Result<()> {
let mut image = Image::parse_file(exe_path)
.with_context(|| format!("parse {}", exe_path.display()))?;
let mut resources = image.resource_directory().cloned().unwrap_or_default();
resources.root_mut().remove(ResourceEntryName::ID(RT_ICON));
resources.root_mut().remove(ResourceEntryName::ID(RT_GROUP_ICON));
let tmp = std::env::temp_dir().join("fromchat-branding.png");
std::fs::write(&tmp, BRANDING_PNG).context("write temp branding png")?;
resources
.set_main_icon_file(
tmp.to_str()
.context("temp icon path is not valid UTF-8")?,
)
.context("set main icon on jpackage exe")?;
image
.set_resource_directory(resources)
.context("apply icon resources to jpackage exe")?;
let temp = exe_path.with_extension("icon-patch.tmp.exe");
if temp.is_file() {
std::fs::remove_file(&temp).ok();
}
image
.write_file(&temp)
.with_context(|| format!("write temp {}", temp.display()))?;
replace_file(&temp, exe_path)
.with_context(|| format!("replace {}", exe_path.display()))?;
let _ = std::fs::remove_file(&tmp);
Ok(())
}
fn replace_file(src: &Path, dest: &Path) -> Result<()> {
if dest.is_file() {
let mut permissions = std::fs::metadata(dest)
.with_context(|| format!("stat {}", dest.display()))?
.permissions();
permissions.set_readonly(false);
std::fs::set_permissions(dest, permissions)
.with_context(|| format!("clear read-only on {}", dest.display()))?;
std::fs::remove_file(dest)
.with_context(|| format!("remove {}", dest.display()))?;
}
std::fs::rename(src, dest).with_context(|| format!("rename to {}", dest.display()))?;
Ok(())
}
@@ -0,0 +1,91 @@
use crate::payload::HIDDEN_APP_EXE;
use crate::FromChatEdition;
use anyhow::{bail, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
/// User-visible launcher in the install root (has the embedded app icon).
pub const VISIBLE_BETA_EXE: &str = "FromChat Beta.exe";
/// Legacy beta launcher name removed on upgrade.
const LEGACY_VISIBLE_BETA_EXE: &str = "FromChat (Beta).exe";
/// Visible launcher name for an edition, if different from [HIDDEN_APP_EXE].
pub fn visible_launcher_name(edition: FromChatEdition) -> Option<&'static str> {
match edition {
FromChatEdition::Release => None,
FromChatEdition::Beta => Some(VISIBLE_BETA_EXE),
}
}
/// Returns the path users should run (visible beta launcher or jpackage exe).
#[cfg(windows)]
pub fn finalize_install_launcher(
dest: &Path,
edition: FromChatEdition,
launcher_bytes: &[u8],
) -> Result<PathBuf> {
let hidden = find_jpackage_app_exe(dest)?;
if let Some(visible_name) = visible_launcher_name(edition) {
let visible = dest.join(visible_name);
fs::write(&visible, launcher_bytes)
.with_context(|| format!("write visible launcher {}", visible.display()))?;
let legacy = dest.join(LEGACY_VISIBLE_BETA_EXE);
if legacy.is_file() {
let _ = fs::remove_file(&legacy);
}
crate::hide_all_except(dest, visible_name)?;
Ok(visible)
} else {
Ok(hidden)
}
}
#[cfg(not(windows))]
pub fn finalize_install_launcher(
dest: &Path,
_edition: FromChatEdition,
_launcher_bytes: &[u8],
) -> Result<PathBuf> {
find_jpackage_app_exe(dest)
}
pub fn find_jpackage_app_exe(dest: &Path) -> Result<PathBuf> {
let candidates = [
dest.join(HIDDEN_APP_EXE),
dest.join("bin").join(HIDDEN_APP_EXE),
];
for candidate in candidates {
if candidate.is_file() {
return Ok(candidate);
}
}
for entry in walk_files(dest)? {
let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.eq_ignore_ascii_case(HIDDEN_APP_EXE) {
return Ok(entry);
}
if name.eq_ignore_ascii_case(VISIBLE_BETA_EXE) {
continue;
}
}
bail!("{HIDDEN_APP_EXE} not found under {}", dest.display());
}
fn walk_files(root: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
fn rec(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
rec(&path, out)?;
} else {
out.push(path);
}
}
Ok(())
}
rec(root, &mut out)?;
Ok(out)
}
@@ -1,7 +1,9 @@
//! Shared payload format, extract, and Windows install helpers for FromChat setup.
mod branding;
mod exe_icon;
mod install_icon;
mod install_layout;
mod payload;
mod progress;
@@ -9,7 +11,9 @@ mod progress;
mod win;
pub use branding::*;
pub use exe_icon::*;
pub use install_icon::*;
pub use install_layout::*;
pub use payload::*;
pub use progress::*;
+217 -11
View File
@@ -5,10 +5,13 @@ use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
pub const SETUP_MAGIC: &[u8; 8] = b"FCHSETUP";
pub const SETUP_MAGIC_V2: &[u8; 8] = b"FCHSETU2";
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";
pub const SETUP_INSTALLER_EXE: &str = "FromChat-Installer.exe";
pub const SETUP_HELPER_EXE: &str = "FromChat-Installer-Helper.exe";
pub const PIPE_NAME: &str = r"\\.\pipe\FromChat-Installer-Helper";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleMeta {
@@ -35,12 +38,37 @@ pub struct EmbeddedBundle {
pub meta: BundleMeta,
pub helper: Vec<u8>,
pub launcher: Vec<u8>,
pub payload_zstd: Vec<u8>,
payload_x64: Option<Vec<u8>>,
payload_arm64: Option<Vec<u8>>,
legacy_payload: Option<Vec<u8>>,
}
impl EmbeddedBundle {
pub fn payload_zstd(&self) -> Result<&[u8]> {
if let Some(payload) = host_payload(self) {
return Ok(payload);
}
bail!("installer has no payload for this CPU ({})", std::env::consts::ARCH)
}
}
fn host_payload(bundle: &EmbeddedBundle) -> Option<&[u8]> {
match std::env::consts::ARCH {
"x86_64" => bundle
.payload_x64
.as_deref()
.or(bundle.legacy_payload.as_deref()),
"aarch64" => bundle
.payload_arm64
.as_deref()
.or(bundle.legacy_payload.as_deref()),
_ => bundle.legacy_payload.as_deref(),
}
}
#[repr(C)]
#[derive(Clone, Copy)]
struct Footer {
struct FooterV1 {
magic: [u8; 8],
meta_off: u64,
meta_len: u64,
@@ -52,7 +80,23 @@ struct Footer {
payload_len: u64,
}
impl Footer {
#[repr(C)]
#[derive(Clone, Copy)]
struct FooterV2 {
magic: [u8; 8],
meta_off: u64,
meta_len: u64,
helper_off: u64,
helper_len: u64,
launcher_off: u64,
launcher_len: u64,
payload_x64_off: u64,
payload_x64_len: u64,
payload_arm64_off: u64,
payload_arm64_len: u64,
}
impl FooterV1 {
const SIZE: usize = 8 + 8 * 8;
fn to_bytes(self) -> [u8; Self::SIZE] {
@@ -100,6 +144,58 @@ impl Footer {
}
}
impl FooterV2 {
const SIZE: usize = 8 + 10 * 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_x64_off,
self.payload_x64_len,
self.payload_arm64_off,
self.payload_arm64_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; 10];
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_x64_off: vals[6],
payload_x64_len: vals[7],
payload_arm64_off: vals[8],
payload_arm64_len: vals[9],
})
}
}
pub fn append_bundle(
stub: &[u8],
magic: &[u8; 8],
@@ -121,7 +217,7 @@ pub fn append_bundle(
file.write_all(launcher)?;
let payload_off = launcher_off + launcher.len() as u64;
file.write_all(payload_zstd)?;
let footer = Footer {
let footer = FooterV1 {
magic: *magic,
meta_off,
meta_len: meta_bytes.len() as u64,
@@ -136,19 +232,92 @@ pub fn append_bundle(
Ok(())
}
pub fn append_setup_bundle_v2(
stub: &[u8],
meta: &BundleMeta,
helper: &[u8],
launcher: &[u8],
payload_x64: Option<&[u8]>,
payload_arm64: Option<&[u8]>,
out: &Path,
) -> Result<()> {
if payload_x64.is_none() && payload_arm64.is_none() {
bail!("at least one app payload is required");
}
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_x64_off, payload_x64_len) = if let Some(payload) = payload_x64 {
let off = launcher_off + launcher.len() as u64;
file.write_all(payload)?;
(off, payload.len() as u64)
} else {
(0, 0)
};
let (payload_arm64_off, payload_arm64_len) = if let Some(payload) = payload_arm64 {
let off = if payload_x64_len > 0 {
payload_x64_off + payload_x64_len
} else {
launcher_off + launcher.len() as u64
};
file.write_all(payload)?;
(off, payload.len() as u64)
} else {
(0, 0)
};
let footer = FooterV2 {
magic: *SETUP_MAGIC_V2,
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_x64_off,
payload_x64_len,
payload_arm64_off,
payload_arm64_len,
};
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 {
if len < FooterV1::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)?;
file.seek(SeekFrom::End(-(FooterV2::SIZE as i64)))?;
let mut footer_v2_buf = [0u8; FooterV2::SIZE];
file.read_exact(&mut footer_v2_buf)?;
if &footer_v2_buf[0..8] == SETUP_MAGIC_V2 && expected_magic == SETUP_MAGIC {
let footer = FooterV2::from_bytes(&footer_v2_buf)?;
return read_bundle_v2(&mut file, footer);
}
file.seek(SeekFrom::End(-(FooterV1::SIZE as i64)))?;
let mut footer_v1_buf = [0u8; FooterV1::SIZE];
file.read_exact(&mut footer_v1_buf)?;
let footer = FooterV1::from_bytes(&footer_v1_buf)?;
if &footer.magic != expected_magic {
bail!("bundle magic mismatch");
}
read_bundle_v1(&mut file, footer)
}
fn read_bundle_v1(file: &mut File, footer: FooterV1) -> Result<EmbeddedBundle> {
let mut meta = vec![0u8; footer.meta_len as usize];
file.seek(SeekFrom::Start(footer.meta_off))?;
file.read_exact(&mut meta)?;
@@ -165,10 +334,47 @@ pub fn read_bundle_from_exe(exe: &Path, expected_magic: &[u8; 8]) -> Result<Embe
meta: serde_json::from_slice(&meta)?,
helper,
launcher,
payload_zstd,
payload_x64: None,
payload_arm64: None,
legacy_payload: Some(payload_zstd),
})
}
fn read_bundle_v2(file: &mut File, footer: FooterV2) -> Result<EmbeddedBundle> {
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 payload_x64 = read_optional_payload(file, footer.payload_x64_off, footer.payload_x64_len)?;
let payload_arm64 =
read_optional_payload(file, footer.payload_arm64_off, footer.payload_arm64_len)?;
Ok(EmbeddedBundle {
meta: serde_json::from_slice(&meta)?,
helper,
launcher,
payload_x64,
payload_arm64,
legacy_payload: None,
})
}
fn read_optional_payload(file: &mut File, offset: u64, len: u64) -> Result<Option<Vec<u8>>> {
if len == 0 {
return Ok(None);
}
let mut payload = vec![0u8; len as usize];
file.seek(SeekFrom::Start(offset))?;
file.read_exact(&mut payload)?;
Ok(Some(payload))
}
pub fn compress_directory_zstd(dir: &Path) -> Result<Vec<u8>> {
let mut builder = tar::Builder::new(Vec::new());
builder.append_dir_all(".", dir)?;
@@ -21,6 +21,7 @@ pub enum HelperCommand {
start_menu: bool,
desktop: bool,
payload_path: String,
launcher_path: String,
uninstaller_path: String,
setup_exe_path: String,
},
@@ -37,6 +38,7 @@ pub enum HelperCommand {
all_users: bool,
edition: String,
payload_path: String,
launcher_path: String,
setup_exe_path: String,
desktop_shortcut: bool,
},
+17 -6
View File
@@ -48,6 +48,9 @@ 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";
/// CLI flag: launch the app when setup finishes (skips the final screen).
pub const LAUNCH_ARG: &str = "--launch";
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
@@ -76,6 +79,11 @@ impl FromChatEdition {
self.display_name()
}
/// Default install directory leaf (`FromChat` or `FromChat Beta`).
pub fn install_folder_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)
@@ -234,9 +242,11 @@ pub fn clear_user_data(install_dir: &Path) -> Result<()> {
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);
for name in ["FromChat", "FromChat Beta"] {
let app_data = PathBuf::from(&local_app_data).join(name);
if app_data.is_dir() {
let _ = fs::remove_dir_all(&app_data);
}
}
}
clear_java_prefs_node(r"Software\JavaSoft\Prefs\ru\fromchat\settings")?;
@@ -580,14 +590,15 @@ pub fn elevate_helper(helper_path: &Path, args: &str) -> Result<()> {
Ok(())
}
pub fn default_install_dir(all_users: bool) -> Result<PathBuf> {
pub fn default_install_dir(all_users: bool, edition: FromChatEdition) -> Result<PathBuf> {
let folder = edition.install_folder_name();
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"))
Ok(PathBuf::from(program_files).join(folder))
} else {
let local = std::env::var("LOCALAPPDATA").context("LOCALAPPDATA")?;
Ok(PathBuf::from(local).join("Programs").join("FromChat"))
Ok(PathBuf::from(local).join("Programs").join(folder))
}
}
+3 -3
View File
@@ -1,16 +1,16 @@
[package]
name = "fromchat-setup-helper"
name = "fromchat-installer-helper"
version.workspace = true
edition.workspace = true
[[bin]]
name = "fromchat-setup-helper"
name = "FromChat-Installer-Helper"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
clap.workspace = true
fromchat-setup-common.workspace = true
fromchat-installer-common.workspace = true
serde_json.workspace = true
[target.'cfg(windows)'.dependencies]
+31 -52
View File
@@ -2,10 +2,11 @@
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 fromchat_installer_common::{
copy_uninstall_setup, create_shortcut, desktop_folder, extract_zstd_tar, finalize_install_launcher,
find_jpackage_app_exe, open_pipe_client, patch_jpackage_exe_icon, 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};
@@ -13,7 +14,7 @@ use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_APARTME
#[derive(Parser)]
struct Args {
#[arg(long, default_value = r"\\.\pipe\fromchat-setup-helper")]
#[arg(long, default_value = r"\\.\pipe\FromChat-Installer-Helper")]
pipe: String,
}
@@ -38,7 +39,7 @@ fn run() -> 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 line = fromchat_installer_common::pipe_read_line(handle)?;
let cmd: HelperCommand = serde_json::from_str(&line)?;
if let Err(error) = dispatch(handle, cmd) {
let _ = pipe_write_line(
@@ -51,7 +52,7 @@ fn run_inner() -> Result<()> {
Ok(())
}
fn dispatch(handle: fromchat_setup_common::PipeHandle, cmd: HelperCommand) -> Result<()> {
fn dispatch(handle: fromchat_installer_common::PipeHandle, cmd: HelperCommand) -> Result<()> {
match cmd {
HelperCommand::Ping => {
pipe_write_line(
@@ -69,12 +70,14 @@ fn dispatch(handle: fromchat_setup_common::PipeHandle, cmd: HelperCommand) -> Re
start_menu,
desktop,
payload_path,
launcher_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 launcher_bytes = fs::read(&launcher_path)?;
let edition = parse_edition(&edition)?;
pipe_write_line(
handle,
@@ -94,8 +97,11 @@ fn dispatch(handle: fromchat_setup_common::PipeHandle, cmd: HelperCommand) -> Re
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 app_exe = find_jpackage_app_exe(&dest)?;
patch_jpackage_exe_icon(&app_exe)
.with_context(|| format!("patch icon on {}", app_exe.display()))?;
let launch_exe = finalize_install_launcher(&dest, edition, &launcher_bytes)?;
let icon = fromchat_installer_common::write_install_icon(&dest)?;
copy_uninstall_setup(&setup_exe_path, &dest)?;
pipe_write_line(
handle,
@@ -116,7 +122,7 @@ fn dispatch(handle: fromchat_setup_common::PipeHandle, cmd: HelperCommand) -> Re
let link = programs_folder(all_users)?.join(&shortcut);
create_shortcut(
&link,
&app_exe,
&launch_exe,
&dest,
edition.display_name(),
&icon,
@@ -126,7 +132,7 @@ fn dispatch(handle: fromchat_setup_common::PipeHandle, cmd: HelperCommand) -> Re
let link = desktop_folder()?.join(&shortcut);
create_shortcut(
&link,
&app_exe,
&launch_exe,
&dest,
edition.display_name(),
&icon,
@@ -139,9 +145,10 @@ fn dispatch(handle: fromchat_setup_common::PipeHandle, cmd: HelperCommand) -> Re
pipe_write_line(
handle,
&serde_json::to_string(&ProgressEvent::Done {
launch_path: app_exe.to_string_lossy().into_owned(),
launch_path: launch_exe.to_string_lossy().into_owned(),
})?,
)?;
let _ = app_exe;
}
HelperCommand::Uninstall {
install_dir,
@@ -184,11 +191,13 @@ fn dispatch(handle: fromchat_setup_common::PipeHandle, cmd: HelperCommand) -> Re
all_users,
edition,
payload_path,
launcher_path,
setup_exe_path,
desktop_shortcut,
} => {
let dest = PathBuf::from(dest);
let setup_exe_path = PathBuf::from(setup_exe_path);
let launcher_bytes = fs::read(&launcher_path)?;
let edition = parse_edition(&edition)?;
pipe_write_line(
handle,
@@ -214,8 +223,11 @@ fn dispatch(handle: fromchat_setup_common::PipeHandle, cmd: HelperCommand) -> Re
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 app_exe = find_jpackage_app_exe(&dest)?;
patch_jpackage_exe_icon(&app_exe)
.with_context(|| format!("patch icon on {}", app_exe.display()))?;
let launch_exe = finalize_install_launcher(&dest, edition, &launcher_bytes)?;
let icon = fromchat_installer_common::write_install_icon(&dest)?;
let uninstaller = copy_uninstall_setup(&setup_exe_path, &dest)?;
pipe_write_line(
handle,
@@ -235,14 +247,14 @@ fn dispatch(handle: fromchat_setup_common::PipeHandle, cmd: HelperCommand) -> Re
let link = programs_folder(all_users)?.join(&shortcut);
create_shortcut(
&link,
&app_exe,
&launch_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)?;
create_shortcut(&link, &launch_exe, &dest, edition.display_name(), &icon)?;
}
pipe_write_line(
handle,
@@ -251,9 +263,10 @@ fn dispatch(handle: fromchat_setup_common::PipeHandle, cmd: HelperCommand) -> Re
pipe_write_line(
handle,
&serde_json::to_string(&ProgressEvent::Done {
launch_path: app_exe.to_string_lossy().into_owned(),
launch_path: launch_exe.to_string_lossy().into_owned(),
})?,
)?;
let _ = app_exe;
}
}
Ok(())
@@ -268,39 +281,5 @@ fn parse_edition(raw: &str) -> Result<FromChatEdition> {
}
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)
find_jpackage_app_exe(dest)
}
@@ -0,0 +1,12 @@
[package]
name = "fromchat-icon-patch"
version.workspace = true
edition.workspace = true
[[bin]]
name = "fromchat-icon-patch"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
fromchat-installer-common = { path = "../common" }
@@ -0,0 +1,25 @@
use anyhow::{bail, Context, Result};
use fromchat_installer_common::patch_jpackage_exe_icon;
use std::env;
use std::path::PathBuf;
fn main() {
if let Err(error) = run() {
eprintln!("fromchat-icon-patch: {error:#}");
std::process::exit(1);
}
}
fn run() -> Result<()> {
let exe = PathBuf::from(
env::args()
.nth(1)
.context("usage: fromchat-icon-patch <FromChat.exe>")?,
);
if !exe.is_file() {
bail!("not a file: {}", exe.display());
}
patch_jpackage_exe_icon(&exe)?;
println!("Patched icon: {}", exe.display());
Ok(())
}
+1 -1
View File
@@ -10,4 +10,4 @@ path = "src/main.rs"
[dependencies]
anyhow.workspace = true
clap.workspace = true
fromchat-setup-common.workspace = true
fromchat-installer-common.workspace = true
+54 -18
View File
@@ -1,15 +1,21 @@
use anyhow::{Context, Result};
use clap::Parser;
use fromchat_setup_common::{
append_bundle, compress_directory_zstd, BundleMeta, BundleMode, SETUP_MAGIC,
use fromchat_installer_common::{
append_bundle, append_setup_bundle_v2, compress_directory_zstd, BundleMeta, BundleMode,
SETUP_MAGIC,
};
use std::fs;
use std::path::PathBuf;
#[derive(Parser, Debug)]
struct Args {
/// Legacy single-arch app image (host CPU only).
#[arg(long)]
app_image: PathBuf,
app_image: Option<PathBuf>,
#[arg(long)]
app_image_x64: Option<PathBuf>,
#[arg(long)]
app_image_arm64: Option<PathBuf>,
#[arg(long)]
version: String,
/// Uninstall registry id baked into the installer (`FromChat` or `FromChat Beta`).
@@ -27,29 +33,59 @@ struct Args {
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)?;
let meta = BundleMeta {
version: args.version,
mode: BundleMode::Setup,
registration_id: args.registration_id,
};
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,
)?;
let payload_x64 = match (&args.app_image_x64, &args.app_image) {
(Some(dir), _) => Some(compress_directory_zstd(dir)?),
(None, Some(dir)) if std::env::consts::ARCH == "x86_64" => {
Some(compress_directory_zstd(dir)?)
}
_ => None,
};
let payload_arm64 = match (&args.app_image_arm64, &args.app_image) {
(Some(dir), _) => Some(compress_directory_zstd(dir)?),
(None, Some(dir)) if std::env::consts::ARCH == "aarch64" => {
Some(compress_directory_zstd(dir)?)
}
_ => None,
};
if payload_x64.is_some() || payload_arm64.is_some() {
append_setup_bundle_v2(
&setup_stub,
&meta,
&helper,
&launcher,
payload_x64.as_deref(),
payload_arm64.as_deref(),
&args.setup_out,
)?;
} else if let Some(app_image) = &args.app_image {
let payload = compress_directory_zstd(app_image)
.with_context(|| format!("compress {}", app_image.display()))?;
append_bundle(
&setup_stub,
SETUP_MAGIC,
&meta,
&helper,
&launcher,
&payload,
&args.setup_out,
)?;
} else {
anyhow::bail!("provide --app-image, --app-image-x64, and/or --app-image-arm64");
}
println!("Wrote {}", args.setup_out.display());
Ok(())
@@ -9,7 +9,7 @@ path = "src/main.rs"
[dependencies]
anyhow.workspace = true
fromchat-setup-common.workspace = true
fromchat-installer-common.workspace = true
[build-dependencies]
winres = "0.1"
@@ -13,6 +13,9 @@ fn embed_windows_icon(png_relative: &str) {
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"));
res.set("FileDescription", "FromChat Beta");
res.set("ProductName", "FromChat Beta");
res.set("OriginalFilename", "FromChat Beta.exe");
if let Err(error) = res.compile() {
panic!("winres compile failed: {error}");
}
@@ -1,20 +1,24 @@
//! 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.
//! Visible portable / beta entrypoint:
//! - SFX portable: extract once beside itself, then relaunch.
//! - Installed beta: launch hidden jpackage exe with beta data-dir JVM flags.
//! - Never uses `JAVA_TOOL_OPTIONS` (breaks when the install path contains spaces).
#![windows_subsystem = "windows"]
use anyhow::{bail, Context, Result};
use fromchat_setup_common::{extract_zstd_tar, read_bundle_from_exe, PORTABLE_MAGIC, VISIBLE_PORTABLE_EXE};
use fromchat_installer_common::{
extract_zstd_tar, read_bundle_from_exe, HIDDEN_APP_EXE, PORTABLE_MAGIC, VISIBLE_BETA_EXE,
VISIBLE_PORTABLE_EXE,
};
#[cfg(windows)]
use fromchat_setup_common::{hide_all_except, set_hidden};
use fromchat_installer_common::{hide_all_except, set_hidden};
use std::env;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
fn main() {
if let Err(e) = run() {
eprintln!("FromChat Portable: {e:#}");
eprintln!("FromChat: {e:#}");
std::process::exit(1);
}
}
@@ -24,7 +28,9 @@ fn run() -> Result<()> {
let dir = exe
.parent()
.map(|p| p.to_path_buf())
.context("portable exe has no parent directory")?;
.context("launcher exe has no parent directory")?;
let mut portable_extract = false;
// Standalone portable download: extract into a sibling folder on first run.
if let Ok(bundle) = read_bundle_from_exe(&exe, PORTABLE_MAGIC) {
@@ -32,9 +38,8 @@ fn run() -> Result<()> {
let marker = target.join(".fromchat-extracted");
if !marker.is_file() {
std::fs::create_dir_all(&target)?;
extract_zstd_tar(&bundle.payload_zstd, &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 {
@@ -58,6 +63,7 @@ fn run() -> Result<()> {
.status()?;
std::process::exit(status.code().unwrap_or(1));
}
portable_extract = true;
}
let app = find_app_exe(&dir)?;
@@ -67,33 +73,61 @@ fn run() -> Result<()> {
.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));
if portable_extract || is_true_portable_layout(&dir) {
push_jvm_arg(&mut cmd, "-Dfromchat.portable=true");
push_jvm_arg(&mut cmd, &format!("-Dfromchat.exe.dir={}", dir.display()));
} else if is_installed_beta_launcher(&exe) {
push_jvm_arg(&mut cmd, "-Dfromchat.app.data.name=FromChatBeta");
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x01000000;
cmd.creation_flags(DETACHED_PROCESS | CREATE_BREAKAWAY_FROM_JOB);
}
let _child = cmd.spawn().with_context(|| format!("spawn {}", app.display()))?;
std::process::exit(0);
}
fn push_jvm_arg(cmd: &mut Command, flag: &str) {
cmd.arg(format!("-J{flag}"));
}
fn is_installed_beta_launcher(exe: &Path) -> bool {
exe.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.eq_ignore_ascii_case(VISIBLE_BETA_EXE))
}
fn is_true_portable_layout(dir: &Path) -> bool {
dir.join("fromchat-data").is_dir() && !dir.join("runtime").is_dir()
}
fn find_app_exe(dir: &PathBuf) -> Result<PathBuf> {
for name in ["FromChat.exe", "bin/FromChat.exe"] {
for name in [HIDDEN_APP_EXE, "bin/FromChat.exe", "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"))
let file_name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
if file_name.eq_ignore_ascii_case(HIDDEN_APP_EXE)
|| file_name.eq_ignore_ascii_case("FromChat.exe")
{
if file_name.eq_ignore_ascii_case(VISIBLE_BETA_EXE)
|| file_name.eq_ignore_ascii_case(VISIBLE_PORTABLE_EXE)
{
continue;
}
return Ok(entry);
}
}
bail!("Hidden FromChat.exe not found next to the portable launcher");
bail!("{HIDDEN_APP_EXE} not found next to the launcher");
}
fn walk(root: &PathBuf) -> Result<Vec<PathBuf>> {
+13 -3
View File
@@ -1,17 +1,26 @@
[package]
name = "fromchat-setup"
name = "fromchat-installer"
version.workspace = true
edition.workspace = true
description = "Установщик FromChat"
[[bin]]
name = "fromchat-setup"
name = "FromChat-Installer"
path = "src/main.rs"
[package.metadata.winres]
FileDescription = "Установщик FromChat"
ProductName = "Установщик FromChat"
InternalName = "Установщик FromChat"
OriginalFilename = "FromChat-Setup.exe"
LegalCopyright = "© FromChat, 2026"
CompanyName = "denis0001-dev"
[dependencies]
anyhow.workspace = true
eframe.workspace = true
egui.workspace = true
fromchat-setup-common.workspace = true
fromchat-installer-common.workspace = true
serde_json.workspace = true
image = { version = "0.25", default-features = false, features = ["png"] }
earcutr = "0.4"
@@ -30,5 +39,6 @@ windows = { version = "0.58", features = [
"Win32_System_Console",
"Win32_System_Diagnostics_Debug",
"Win32_System_EventLog",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
+58 -2
View File
@@ -1,11 +1,12 @@
fn main() {
embed_windows_icon("../assets/app_window_icon.png");
embed_windows_resources("../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");
println!("cargo:rerun-if-env-changed=FROMCHAT_SETUP_VERSION");
}
fn embed_windows_icon(png_relative: &str) {
fn embed_windows_resources(png_relative: &str) {
if std::env::var("CARGO_CFG_TARGET_OS").ok().as_deref() != Some("windows") {
return;
}
@@ -14,13 +15,68 @@ fn embed_windows_icon(png_relative: &str) {
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 version = std::env::var("FROMCHAT_SETUP_VERSION")
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string());
let version_string = four_part_version_string(&version);
const DISPLAY_NAME: &str = "Установщик FromChat";
let mut res = winres::WindowsResource::new();
res.set_icon(ico_path.to_str().expect("ico path utf-8"));
// Russian (0x0419) + Unicode code page so Task Manager reads FileDescription.
res.set_language(0x0419);
res.set("FileDescription", DISPLAY_NAME);
res.set("ProductName", DISPLAY_NAME);
res.set("CompanyName", "denis0001-dev");
res.set("LegalCopyright", "© FromChat, 2026");
res.set("InternalName", DISPLAY_NAME);
res.set("OriginalFilename", "FromChat-Setup.exe");
res.set("FileVersion", &version_string);
res.set("ProductVersion", &version_string);
res.set_version_info(
winres::VersionInfo::FILEVERSION,
version_string_to_u64(&version),
);
res.set_version_info(
winres::VersionInfo::PRODUCTVERSION,
version_string_to_u64(&version),
);
if let Err(error) = res.compile() {
panic!("winres compile failed: {error}");
}
}
fn four_part_version_string(version: &str) -> String {
let mut parts: Vec<u16> = version
.split('.')
.map(|part| part.parse().unwrap_or(0))
.collect();
while parts.len() < 4 {
parts.push(0);
}
parts.truncate(4);
format!(
"{}.{}.{}.{}",
parts[0], parts[1], parts[2], parts[3]
)
}
fn version_string_to_u64(version: &str) -> u64 {
let mut parts: Vec<u16> = version
.split('.')
.map(|part| part.parse().unwrap_or(0))
.collect();
while parts.len() < 4 {
parts.push(0);
}
parts.truncate(4);
((parts[0] as u64) << 48)
| ((parts[1] as u64) << 32)
| ((parts[2] as u64) << 16)
| (parts[3] as u64)
}
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);
+6 -1
View File
@@ -1,4 +1,4 @@
use fromchat_setup_common::{find_installed_by_registration_id, UNINSTALL_ARG, UPGRADE_ARG};
use fromchat_installer_common::{find_installed_by_registration_id, LAUNCH_ARG, UNINSTALL_ARG, UPGRADE_ARG};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SetupMode {
@@ -11,12 +11,15 @@ pub enum SetupMode {
#[derive(Debug, Clone)]
pub struct SetupOptions {
pub mode: SetupMode,
/// When true, spawn the installed app and close without the Done screen.
pub launch_after: bool,
}
impl Default for SetupOptions {
fn default() -> Self {
Self {
mode: SetupMode::Install,
launch_after: false,
}
}
}
@@ -49,6 +52,7 @@ pub fn parse_setup_args(registration_id: &str) -> Result<SetupOptions, SetupLaun
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);
let launch_after = args.iter().any(|arg| arg == LAUNCH_ARG);
if uninstall && upgrade {
return Err(SetupLaunchError::ConflictingFlags);
@@ -68,6 +72,7 @@ pub fn parse_setup_args(registration_id: &str) -> Result<SetupOptions, SetupLaun
} else {
SetupMode::Install
},
launch_after,
})
}
@@ -1,5 +1,8 @@
//! Russian installer copy (primary locale for Windows setup).
/// PE metadata + Task Manager / winit application id.
pub const INSTALLER_DISPLAY_NAME: &str = "Установщик FromChat";
pub const WINDOW_TITLE: &str = "Установка FromChat";
pub const WINDOW_TITLE_UNINSTALL: &str = "Удаление FromChat";
pub const WINDOW_TITLE_UPGRADE: &str = "Обновление FromChat";
+5 -2
View File
@@ -19,12 +19,15 @@ mod win_log;
use anyhow::Result;
use eframe::egui;
use fromchat_setup_common::{read_bundle_from_exe, BRANDING_PNG, SETUP_MAGIC};
use fromchat_installer_common::{read_bundle_from_exe, BRANDING_PNG, SETUP_MAGIC};
fn main() {
#[cfg(windows)]
win_console::configure();
#[cfg(windows)]
win_chrome::set_process_app_user_model_id();
win_log::init();
if let Err(error) = run() {
@@ -107,7 +110,7 @@ fn run() -> Result<()> {
let setup_options = options.clone();
let result = eframe::run_native(
&window_title,
i18n::INSTALLER_DISPLAY_NAME,
options_native,
Box::new(move |cc| {
win_log::info("eframe creation callback");
+113 -84
View File
@@ -1,3 +1,4 @@
use anyhow::Context;
use crate::anim::{Lerped, ScreenTransition};
use crate::args::{SetupMode, SetupOptions};
use crate::fonts::{self, body, body_medium, wordmark_brand, title_large};
@@ -6,11 +7,13 @@ use crate::icons::{self, CLOSE, DELETE, DOWNLOAD, FOLDER, DONE_ALL, MINIMIZE, OP
use crate::theme::{self, ON_SURFACE, ON_SURFACE_VARIANT, ON_ERROR_FILLED, PRIMARY, SURFACE, TITLE_BAR_HEIGHT, TITLE_BAR_INACTIVE};
use crate::widgets::{self, H_PADDING};
use eframe::egui::{self, Color32, Pos2, Rect, Rounding, Sense, TextureHandle, Ui, Vec2};
use fromchat_setup_common::{
use fromchat_installer_common::{
copy_uninstall_setup, extract_zstd_tar, find_installed_by_registration_id,
load_install_display_icon, read_bundle_from_exe, uninstall_fromchat, wipe_install_dir,
write_install_icon, FromChatEdition, HelperCommand, InstalledFromChat, ProgressEvent,
BRANDING_PNG, SETUP_MAGIC, UNINSTALL_SETUP_EXE, VISIBLE_PORTABLE_EXE,
write_install_icon, finalize_install_launcher, find_jpackage_app_exe, FromChatEdition,
patch_jpackage_exe_icon,
HelperCommand, InstalledFromChat, ProgressEvent,
BRANDING_PNG, SETUP_HELPER_EXE, SETUP_INSTALLER_EXE, SETUP_MAGIC, UNINSTALL_SETUP_EXE, VISIBLE_PORTABLE_EXE,
};
use std::collections::HashMap;
use std::path::PathBuf;
@@ -31,13 +34,12 @@ enum Screen {
#[cfg(windows)]
struct ElevatedInstallSession {
pipe: fromchat_setup_common::NamedPipeServer,
pipe: fromchat_installer_common::NamedPipeServer,
cmd_json: String,
}
pub struct SetupApp {
mode: SetupMode,
window_title: &'static str,
screen: Screen,
transition: Option<ScreenTransition<Screen>>,
time: f32,
@@ -62,10 +64,13 @@ pub struct SetupApp {
installed: Option<InstalledFromChat>,
preserve_user_data: bool,
silent_upgrade: bool,
launch_after: bool,
pending_auto_upgrade: bool,
is_uninstalling: bool,
is_upgrading: bool,
version: String,
edition: FromChatEdition,
synced_native_window_title: Option<&'static str>,
logged_first_frame: bool,
applied_window_chrome: bool,
}
@@ -103,8 +108,10 @@ impl SetupApp {
.as_ref()
.map(|b| b.meta.registration_id.clone())
.unwrap_or_else(|_| "FromChat".to_owned());
let edition = FromChatEdition::from_registry_key(&registration_id)
.unwrap_or(FromChatEdition::Release);
let install_path = default_install_path(false);
let install_path = default_install_path(false, edition);
let portable_path = std::env::current_dir()
.map(|p| p.join("FromChat").to_string_lossy().into_owned())
.unwrap_or_else(|_| r".\FromChat".into());
@@ -121,9 +128,8 @@ impl SetupApp {
None
};
let window_title = crate::args::window_title_for_mode(options.mode);
let silent_upgrade = matches!(options.mode, SetupMode::Upgrade);
let launch_after = options.launch_after;
let pending_auto_upgrade = silent_upgrade;
let screen = match options.mode {
@@ -134,7 +140,6 @@ impl SetupApp {
Self {
mode: options.mode,
window_title,
screen,
transition: None,
time: 0.0,
@@ -159,15 +164,34 @@ impl SetupApp {
installed,
preserve_user_data: true,
silent_upgrade,
launch_after,
pending_auto_upgrade,
is_uninstalling: matches!(options.mode, SetupMode::Uninstall),
is_upgrading: silent_upgrade,
version,
edition,
synced_native_window_title: None,
logged_first_frame: false,
applied_window_chrome: false,
}
}
fn effective_window_title(&self) -> &'static str {
crate::args::window_title_for_mode(self.mode)
}
fn sync_native_window_title(&mut self, ctx: &egui::Context, frame: &eframe::Frame) {
if self.synced_native_window_title.is_some() {
return;
}
self.synced_native_window_title = Some(i18n::INSTALLER_DISPLAY_NAME);
ctx.send_viewport_cmd(egui::ViewportCommand::Title(
i18n::INSTALLER_DISPLAY_NAME.into(),
));
#[cfg(windows)]
crate::win_chrome::set_window_title(frame, i18n::INSTALLER_DISPLAY_NAME);
}
fn should_confirm_close(&self) -> bool {
!self.silent_upgrade && (self.screen == Screen::Progress || self.elevation_pending())
}
@@ -258,7 +282,10 @@ impl eframe::App for SetupApp {
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
if !self.applied_window_chrome {
self.applied_window_chrome = true;
crate::win_chrome::apply(frame);
crate::win_chrome::apply(frame, i18n::INSTALLER_DISPLAY_NAME);
self.synced_native_window_title = Some(i18n::INSTALLER_DISPLAY_NAME);
} else {
self.sync_native_window_title(ctx, frame);
}
if !self.logged_first_frame {
self.logged_first_frame = true;
@@ -334,7 +361,10 @@ impl eframe::App for SetupApp {
self.progress.set_target(fraction.clamp(0.0, 1.0));
}
ProgressEvent::Done { launch_path } => {
if self.silent_upgrade {
if self.launch_after {
let _ = std::process::Command::new(&launch_path).spawn();
}
if self.silent_upgrade || self.launch_after {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} else {
self.launch_path = Some(PathBuf::from(launch_path));
@@ -403,7 +433,7 @@ impl eframe::App for SetupApp {
welcome_visible,
&self.logo,
ctx.input(|i| i.focused),
self.window_title,
self.effective_window_title(),
&mut || close_requested = true,
&mut || minimize_requested = true,
);
@@ -583,7 +613,8 @@ impl SetupApp {
let was_all_users = self.all_users;
widgets::checkbox(ui, &mut self.all_users, i18n::ALL_USERS, hover_enabled);
if self.all_users != was_all_users {
self.install_path = default_install_path(self.all_users);
self.install_path =
default_install_path(self.all_users, self.edition);
}
widgets::checkbox(
ui,
@@ -1199,7 +1230,7 @@ impl SetupApp {
self.is_upgrading = false;
#[cfg(windows)]
if !portable && self.all_users && !fromchat_setup_common::is_elevated() {
if !portable && self.all_users && !fromchat_installer_common::is_elevated() {
let install_path = PathBuf::from(&self.install_path);
let desktop_shortcut = self.desktop_shortcut;
let (tx, rx) = mpsc::channel();
@@ -1268,7 +1299,7 @@ impl SetupApp {
let preserve_user_data = self.preserve_user_data;
#[cfg(windows)]
if installed.all_users && !fromchat_setup_common::is_elevated() {
if installed.all_users && !fromchat_installer_common::is_elevated() {
let (tx, rx) = mpsc::channel();
self.elevation_rx = Some(rx);
thread::spawn(move || {
@@ -1298,7 +1329,7 @@ impl SetupApp {
};
#[cfg(windows)]
if installed.all_users && !fromchat_setup_common::is_elevated() {
if installed.all_users && !fromchat_installer_common::is_elevated() {
let (tx, rx) = mpsc::channel();
self.elevation_rx = Some(rx);
thread::spawn(move || {
@@ -1375,7 +1406,7 @@ fn run_portable(tx: Sender<ProgressEvent>, dest: PathBuf) -> anyhow::Result<()>
let _ = tx.send(ProgressEvent::Status {
message: i18n::EXTRACTING.into(),
});
extract_zstd_tar(&bundle.payload_zstd, &dest)?;
extract_zstd_tar(bundle.payload_zstd()?, &dest)?;
let _ = tx.send(ProgressEvent::Progress { fraction: 0.7 });
let launcher_path = dest.join(VISIBLE_PORTABLE_EXE);
std::fs::write(&launcher_path, &bundle.launcher)?;
@@ -1384,10 +1415,10 @@ fn run_portable(tx: Sender<ProgressEvent>, dest: PathBuf) -> anyhow::Result<()>
});
#[cfg(windows)]
{
fromchat_setup_common::hide_all_except(&dest, VISIBLE_PORTABLE_EXE)?;
fromchat_installer_common::hide_all_except(&dest, VISIBLE_PORTABLE_EXE)?;
let data = dest.join("fromchat-data");
std::fs::create_dir_all(&data)?;
fromchat_setup_common::set_hidden(&data, true)?;
fromchat_installer_common::set_hidden(&data, true)?;
}
let _ = tx.send(ProgressEvent::Progress { fraction: 1.0 });
let _ = tx.send(ProgressEvent::Done {
@@ -1416,9 +1447,12 @@ fn run_full_install(
});
let _ = tx.send(ProgressEvent::Progress { fraction: 0.2 });
std::fs::create_dir_all(&dest)?;
extract_zstd_tar(&bundle.payload_zstd, &dest)?;
extract_zstd_tar(bundle.payload_zstd()?, &dest)?;
let _ = tx.send(ProgressEvent::Progress { fraction: 0.75 });
let app_exe = find_app_exe(&dest)?;
let app_exe = find_jpackage_app_exe(&dest)?;
patch_jpackage_exe_icon(&app_exe)
.with_context(|| format!("patch icon on {}", app_exe.display()))?;
let launch_exe = finalize_install_launcher(&dest, edition, &bundle.launcher)?;
#[cfg(windows)]
{
let _ = tx.send(ProgressEvent::Status {
@@ -1426,7 +1460,7 @@ fn run_full_install(
});
let icon = write_install_icon(&dest)?;
let uninstaller = copy_uninstall_setup(&exe, &dest)?;
fromchat_setup_common::write_uninstall_registry(
fromchat_installer_common::write_uninstall_registry(
all_users,
edition,
&version,
@@ -1435,29 +1469,30 @@ fn run_full_install(
&icon,
)?;
if desktop_shortcut {
let desk = fromchat_setup_common::desktop_folder()?;
let desk = fromchat_installer_common::desktop_folder()?;
let link = desk.join(format!("{}.lnk", edition.shortcut_name()));
fromchat_setup_common::create_shortcut(
fromchat_installer_common::create_shortcut(
&link,
&app_exe,
&launch_exe,
&dest,
edition.display_name(),
&icon,
)?;
}
let programs = fromchat_setup_common::programs_folder(all_users)?;
let programs = fromchat_installer_common::programs_folder(all_users)?;
let link = programs.join(format!("{}.lnk", edition.shortcut_name()));
fromchat_setup_common::create_shortcut(
fromchat_installer_common::create_shortcut(
&link,
&app_exe,
&launch_exe,
&dest,
edition.display_name(),
&icon,
)?;
}
let _ = app_exe;
let _ = tx.send(ProgressEvent::Progress { fraction: 1.0 });
let _ = tx.send(ProgressEvent::Done {
launch_path: app_exe.to_string_lossy().into_owned(),
launch_path: launch_exe.to_string_lossy().into_owned(),
});
Ok(())
}
@@ -1471,14 +1506,16 @@ fn begin_elevated_install(
let bundle = read_bundle_from_exe(&exe, SETUP_MAGIC)?;
let edition = FromChatEdition::from_registry_key(&bundle.meta.registration_id)
.unwrap_or(FromChatEdition::Release);
let temp = std::env::temp_dir().join("fromchat-setup-payload");
let temp = std::env::temp_dir().join("FromChat-Installer-payload");
let _ = std::fs::remove_dir_all(&temp);
std::fs::create_dir_all(&temp)?;
let payload_path = temp.join("payload.zst");
std::fs::write(&payload_path, &bundle.payload_zstd)?;
let helper_path = temp.join("fromchat-setup-helper.exe");
std::fs::write(&payload_path, bundle.payload_zstd()?)?;
let launcher_path = temp.join("fromchat-launcher.exe");
std::fs::write(&launcher_path, &bundle.launcher)?;
let helper_path = temp.join(SETUP_HELPER_EXE);
std::fs::write(&helper_path, &bundle.helper)?;
let setup_copy = temp.join("fromchat-setup.exe");
let setup_copy = temp.join(SETUP_INSTALLER_EXE);
std::fs::copy(&exe, &setup_copy)?;
let uninstaller = dest.join(UNINSTALL_SETUP_EXE);
let cmd = HelperCommand::Install {
@@ -1489,14 +1526,15 @@ fn begin_elevated_install(
start_menu: true,
desktop: desktop_shortcut,
payload_path: payload_path.to_string_lossy().into_owned(),
launcher_path: launcher_path.to_string_lossy().into_owned(),
uninstaller_path: uninstaller.to_string_lossy().into_owned(),
setup_exe_path: setup_copy.to_string_lossy().into_owned(),
};
let cmd_json = serde_json::to_string(&cmd)?;
let pipe = fromchat_setup_common::NamedPipeServer::create(fromchat_setup_common::PIPE_NAME)?;
fromchat_setup_common::elevate_helper(
let pipe = fromchat_installer_common::NamedPipeServer::create(fromchat_installer_common::PIPE_NAME)?;
fromchat_installer_common::elevate_helper(
&helper_path,
&format!("--pipe {}", fromchat_setup_common::PIPE_NAME),
&format!("--pipe {}", fromchat_installer_common::PIPE_NAME),
)?;
pipe.accept()?;
Ok(ElevatedInstallSession { pipe, cmd_json })
@@ -1509,10 +1547,10 @@ fn begin_elevated_uninstall(
) -> anyhow::Result<ElevatedInstallSession> {
let exe = std::env::current_exe()?;
let bundle = read_bundle_from_exe(&exe, SETUP_MAGIC)?;
let temp = std::env::temp_dir().join("fromchat-setup-payload");
let temp = std::env::temp_dir().join("FromChat-Installer-payload");
let _ = std::fs::remove_dir_all(&temp);
std::fs::create_dir_all(&temp)?;
let helper_path = temp.join("fromchat-setup-helper.exe");
let helper_path = temp.join(SETUP_HELPER_EXE);
std::fs::write(&helper_path, &bundle.helper)?;
let cmd = HelperCommand::Uninstall {
install_dir: installed.install_dir.to_string_lossy().into_owned(),
@@ -1524,10 +1562,10 @@ fn begin_elevated_uninstall(
preserve_user_data,
};
let cmd_json = serde_json::to_string(&cmd)?;
let pipe = fromchat_setup_common::NamedPipeServer::create(fromchat_setup_common::PIPE_NAME)?;
fromchat_setup_common::elevate_helper(
let pipe = fromchat_installer_common::NamedPipeServer::create(fromchat_installer_common::PIPE_NAME)?;
fromchat_installer_common::elevate_helper(
&helper_path,
&format!("--pipe {}", fromchat_setup_common::PIPE_NAME),
&format!("--pipe {}", fromchat_installer_common::PIPE_NAME),
)?;
pipe.accept()?;
Ok(ElevatedInstallSession { pipe, cmd_json })
@@ -1537,14 +1575,16 @@ fn begin_elevated_uninstall(
fn begin_elevated_upgrade(installed: &InstalledFromChat) -> anyhow::Result<ElevatedInstallSession> {
let exe = std::env::current_exe()?;
let bundle = read_bundle_from_exe(&exe, SETUP_MAGIC)?;
let temp = std::env::temp_dir().join("fromchat-setup-payload");
let temp = std::env::temp_dir().join("FromChat-Installer-payload");
let _ = std::fs::remove_dir_all(&temp);
std::fs::create_dir_all(&temp)?;
let payload_path = temp.join("payload.zst");
std::fs::write(&payload_path, &bundle.payload_zstd)?;
let helper_path = temp.join("fromchat-setup-helper.exe");
std::fs::write(&payload_path, bundle.payload_zstd()?)?;
let launcher_path = temp.join("fromchat-launcher.exe");
std::fs::write(&launcher_path, &bundle.launcher)?;
let helper_path = temp.join(SETUP_HELPER_EXE);
std::fs::write(&helper_path, &bundle.helper)?;
let setup_copy = temp.join("fromchat-setup.exe");
let setup_copy = temp.join(SETUP_INSTALLER_EXE);
std::fs::copy(&exe, &setup_copy)?;
let cmd = HelperCommand::Upgrade {
dest: installed.install_dir.to_string_lossy().into_owned(),
@@ -1552,14 +1592,15 @@ fn begin_elevated_upgrade(installed: &InstalledFromChat) -> anyhow::Result<Eleva
all_users: installed.all_users,
edition: edition_tag(installed.edition).into(),
payload_path: payload_path.to_string_lossy().into_owned(),
launcher_path: launcher_path.to_string_lossy().into_owned(),
setup_exe_path: setup_copy.to_string_lossy().into_owned(),
desktop_shortcut: desktop_shortcut_exists(installed),
};
let cmd_json = serde_json::to_string(&cmd)?;
let pipe = fromchat_setup_common::NamedPipeServer::create(fromchat_setup_common::PIPE_NAME)?;
fromchat_setup_common::elevate_helper(
let pipe = fromchat_installer_common::NamedPipeServer::create(fromchat_installer_common::PIPE_NAME)?;
fromchat_installer_common::elevate_helper(
&helper_path,
&format!("--pipe {}", fromchat_setup_common::PIPE_NAME),
&format!("--pipe {}", fromchat_installer_common::PIPE_NAME),
)?;
pipe.accept()?;
Ok(ElevatedInstallSession { pipe, cmd_json })
@@ -1602,9 +1643,12 @@ fn run_upgrade(
let _ = tx.send(ProgressEvent::Progress { fraction: 0.2 });
wipe_install_dir(&dest, None)?;
std::fs::create_dir_all(&dest)?;
extract_zstd_tar(&bundle.payload_zstd, &dest)?;
extract_zstd_tar(bundle.payload_zstd()?, &dest)?;
let _ = tx.send(ProgressEvent::Progress { fraction: 0.75 });
let app_exe = find_app_exe(&dest)?;
let app_exe = find_jpackage_app_exe(&dest)?;
patch_jpackage_exe_icon(&app_exe)
.with_context(|| format!("patch icon on {}", app_exe.display()))?;
let launch_exe = finalize_install_launcher(&dest, installed.edition, &bundle.launcher)?;
#[cfg(windows)]
{
let _ = tx.send(ProgressEvent::Status {
@@ -1612,7 +1656,7 @@ fn run_upgrade(
});
let icon = write_install_icon(&dest)?;
let uninstaller = copy_uninstall_setup(&exe, &dest)?;
fromchat_setup_common::write_uninstall_registry(
fromchat_installer_common::write_uninstall_registry(
installed.all_users,
installed.edition,
&version,
@@ -1621,28 +1665,29 @@ fn run_upgrade(
&icon,
)?;
let shortcut = format!("{}.lnk", installed.edition.shortcut_name());
let programs = fromchat_setup_common::programs_folder(installed.all_users)?;
fromchat_setup_common::create_shortcut(
let programs = fromchat_installer_common::programs_folder(installed.all_users)?;
fromchat_installer_common::create_shortcut(
&programs.join(&shortcut),
&app_exe,
&launch_exe,
&dest,
installed.edition.display_name(),
&icon,
)?;
if desktop_shortcut {
let desk = fromchat_setup_common::desktop_folder()?;
fromchat_setup_common::create_shortcut(
let desk = fromchat_installer_common::desktop_folder()?;
fromchat_installer_common::create_shortcut(
&desk.join(&shortcut),
&app_exe,
&launch_exe,
&dest,
installed.edition.display_name(),
&icon,
)?;
}
}
let _ = app_exe;
let _ = tx.send(ProgressEvent::Progress { fraction: 1.0 });
let _ = tx.send(ProgressEvent::Done {
launch_path: app_exe.to_string_lossy().into_owned(),
launch_path: launch_exe.to_string_lossy().into_owned(),
});
Ok(())
}
@@ -1657,7 +1702,7 @@ fn edition_tag(edition: FromChatEdition) -> &'static str {
fn desktop_shortcut_exists(installed: &InstalledFromChat) -> bool {
#[cfg(windows)]
{
fromchat_setup_common::desktop_folder()
fromchat_installer_common::desktop_folder()
.ok()
.is_some_and(|desk| {
desk.join(format!("{}.lnk", installed.edition.shortcut_name()))
@@ -1676,10 +1721,10 @@ fn run_elevated_install(
session: ElevatedInstallSession,
tx: Sender<ProgressEvent>,
) -> anyhow::Result<()> {
fromchat_setup_common::pipe_write_line(session.pipe.handle(), &session.cmd_json)?;
fromchat_installer_common::pipe_write_line(session.pipe.handle(), &session.cmd_json)?;
let mut got_terminal = false;
loop {
let resp = match fromchat_setup_common::pipe_read_line(session.pipe.handle()) {
let resp = match fromchat_installer_common::pipe_read_line(session.pipe.handle()) {
Ok(resp) => resp,
Err(_error) if got_terminal => return Ok(()),
Err(error) => return Err(error.into()),
@@ -1705,24 +1750,7 @@ fn run_elevated_install(
}
fn find_app_exe(dest: &PathBuf) -> anyhow::Result<PathBuf> {
let candidates = [
dest.join("FromChat.exe"),
dest.join("bin").join("FromChat.exe"),
];
for c in candidates {
if c.is_file() {
return Ok(c);
}
}
for entry in walkdir_files(dest)? {
if entry
.file_name()
.is_some_and(|n| n.eq_ignore_ascii_case("FromChat.exe"))
{
return Ok(entry);
}
}
anyhow::bail!("FromChat.exe not found after extract");
find_jpackage_app_exe(dest).map_err(Into::into)
}
fn walkdir_files(root: &PathBuf) -> anyhow::Result<Vec<PathBuf>> {
@@ -1743,17 +1771,18 @@ fn walkdir_files(root: &PathBuf) -> anyhow::Result<Vec<PathBuf>> {
Ok(out)
}
fn default_install_path(all_users: bool) -> String {
fn default_install_path(all_users: bool, edition: FromChatEdition) -> String {
#[cfg(windows)]
{
fromchat_setup_common::default_install_dir(all_users)
let folder = edition.install_folder_name();
fromchat_installer_common::default_install_dir(all_users, edition)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| {
if all_users {
r"C:\Program Files\FromChat".into()
format!(r"C:\Program Files\{folder}")
} else {
format!(
r"{}\Programs\FromChat",
r"{}\Programs\{folder}",
std::env::var("LOCALAPPDATA").unwrap_or_else(|_| r"C:\Users\Public".into())
)
}
@@ -1762,7 +1791,7 @@ fn default_install_path(all_users: bool) -> String {
#[cfg(not(windows))]
{
let _ = all_users;
"/tmp/FromChat".into()
format!("/tmp/{}", edition.install_folder_name())
}
}
@@ -1,7 +1,49 @@
//! Native Windows 11 window chrome (rounded corners via DWM).
/// Stable identity for taskbar / jump lists. Must be set before any HWND is created.
#[cfg(windows)]
pub fn apply(frame: &eframe::Frame) {
pub fn set_process_app_user_model_id() {
use windows::core::w;
use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
let result = unsafe { SetCurrentProcessExplicitAppUserModelID(w!("denis0001-dev.FromChat.Installer")) };
if let Err(error) = result {
crate::win_log::warn(&format!(
"win_chrome: SetCurrentProcessExplicitAppUserModelID failed: {error:?}"
));
} else {
crate::win_log::info("win_chrome: AppUserModelID set");
}
}
#[cfg(not(windows))]
pub fn set_process_app_user_model_id() {}
#[cfg(windows)]
pub fn apply(frame: &eframe::Frame, title: &str) {
apply_rounded_corners(frame);
set_window_title(frame, title);
}
#[cfg(windows)]
pub fn set_window_title(frame: &eframe::Frame, title: &str) {
use windows::core::PCWSTR;
use windows::Win32::UI::WindowsAndMessaging::SetWindowTextW;
let hwnd = crate::win_dialog::hwnd_from_frame(frame);
if hwnd.0.is_null() {
crate::win_log::warn("win_chrome: set_window_title skipped (no hwnd)");
return;
}
let wide: Vec<u16> = title.encode_utf16().chain(std::iter::once(0)).collect();
let result = unsafe { SetWindowTextW(hwnd, PCWSTR(wide.as_ptr())) };
if let Err(error) = result {
crate::win_log::warn(&format!("win_chrome: SetWindowTextW failed: {error:?}"));
}
}
#[cfg(windows)]
fn apply_rounded_corners(frame: &eframe::Frame) {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use windows::Win32::Foundation::HWND;
use windows::Win32::Graphics::Dwm::{
@@ -34,4 +76,7 @@ pub fn apply(frame: &eframe::Frame) {
}
#[cfg(not(windows))]
pub fn apply(_frame: &eframe::Frame) {}
pub fn apply(_frame: &eframe::Frame, _title: &str) {}
#[cfg(not(windows))]
pub fn set_window_title(_frame: &eframe::Frame, _title: &str) {}
@@ -7,7 +7,9 @@ pub fn configure() {
let cli_mode = std::env::args().any(|arg| {
matches!(
arg.as_str(),
fromchat_setup_common::UPGRADE_ARG | fromchat_setup_common::UNINSTALL_ARG
fromchat_installer_common::UPGRADE_ARG
| fromchat_installer_common::UNINSTALL_ARG
| fromchat_installer_common::LAUNCH_ARG
)
});
+24 -3
View File
@@ -171,7 +171,16 @@ kotlin {
}
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
if (System.getProperty("os.name").orEmpty().lowercase().contains("win") &&
(project.findProperty("windowsArm64") != null || hostCpuArch() == "aarch64")
) {
val composeDesktopVersion = libs.versions.compose.multiplatform.get()
implementation(
"org.jetbrains.compose.desktop:desktop-jvm-windows-arm64:$composeDesktopVersion",
)
} else {
implementation(compose.desktop.currentOs)
}
implementation(libs.jetbrains.kotlinx.io.bytestring)
implementation(libs.jetbrains.kotlinx.coroutines.core)
implementation(libs.ktor.client.cio)
@@ -187,14 +196,26 @@ kotlin {
exclude(group = "org.openjfx")
}
}
implementation("net.java.dev.jna:jna-platform:5.15.0")
}
}
}
private fun hostCpuArch(): String {
val arch = System.getProperty("os.arch").orEmpty().lowercase()
if (arch.contains("aarch64") || arch == "arm64") return "aarch64"
val nativeArch = System.getenv("PROCESSOR_ARCHITEW6432")?.uppercase()
?: System.getenv("PROCESSOR_ARCHITECTURE")?.uppercase()
if (nativeArch == "ARM64") return "aarch64"
return when {
arch.contains("amd64") || arch == "x86_64" -> "x86_64"
else -> arch
}
}
private fun openjfxClassifier(): String {
val os = System.getProperty("os.name").lowercase()
val arch = System.getProperty("os.arch").lowercase()
val isArm = arch == "aarch64" || arch == "arm64"
val isArm = hostCpuArch() == "aarch64"
return when {
os.contains("mac") && isArm -> "mac-aarch64"
os.contains("mac") -> "mac"
@@ -0,0 +1,3 @@
package ru.fromchat.ui.chat
internal actual fun usesMacOsOffscreenContextMenu(): Boolean = false
@@ -1,27 +1,21 @@
package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
@Composable
@Suppress("UNUSED_PARAMETER")
internal actual fun MessageContextMenuPopup(
onDismissRequest: () -> Unit,
positionProvider: PopupPositionProvider,
reserveOvershoot: Boolean,
deferReveal: Boolean,
content: @Composable () -> Unit,
) {
Popup(
InAppContextMenuPopup(
onDismissRequest = onDismissRequest,
popupPositionProvider = positionProvider,
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true,
// Let the spring draw past the laid-out bounds while [reserveOvershoot] is true.
clippingEnabled = !reserveOvershoot,
),
positionProvider = positionProvider,
reserveOvershoot = reserveOvershoot,
content = content,
)
}
@@ -18,6 +18,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -28,6 +29,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
@@ -495,24 +497,6 @@ fun App(
LocalChatFullscreenImageController provides fullscreenImageController,
) {
if (startDestination != null) {
val currentEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentEntry?.destination?.route
val settingsEntry by settingsDetailNavController.currentBackStackEntryAsState()
val settingsRoute = settingsEntry?.destination?.route
val settingsProfileUserId = standaloneProfileUserId(settingsEntry)
val ownUserId = ApiClient.user?.id
val showConversationListDetail =
isDesktopListDetail && currentRoute == "chat"
val forceSettingsListTab =
showConversationListDetail && (
isEditProfileRoute(settingsRoute) ||
(
isStandaloneProfileRoute(settingsRoute) &&
settingsProfileUserId != null &&
settingsProfileUserId == ownUserId
)
)
LaunchedEffect(Unit) {
DesktopMenuCommands.commands.collect { command ->
if (command != DesktopMenuCommand.OpenAbout) return@collect
@@ -604,7 +588,7 @@ fun App(
// empty placeholders live in Desktop*DetailNavHost only.
// Drawing EmptyConversationPlaceholder here ghosts a
// second copy through the transparent chats detail pane.
if (!showConversationListDetail) {
if (!isDesktopListDetail) {
MainScreen(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
@@ -687,7 +671,34 @@ fun App(
AppNavHost(Modifier.fillMaxSize())
// Observe back stack only after [AppNavHost] has set the graph.
val currentEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentEntry?.destination?.route
val showConversationListDetail =
isDesktopListDetail && currentRoute == "chat"
if (showConversationListDetail) {
var detailNavGraphsReady by remember { mutableStateOf(false) }
var settingsRoute by remember { mutableStateOf<String?>(null) }
var settingsProfileUserId by remember { mutableStateOf<Int?>(null) }
if (detailNavGraphsReady) {
SettingsDetailBackStackObserver(
navController = settingsDetailNavController,
onChanged = { entry ->
settingsRoute = entry?.destination?.route
settingsProfileUserId = standaloneProfileUserId(entry)
},
)
}
val ownUserId = ApiClient.user?.id
val forceSettingsListTab =
isEditProfileRoute(settingsRoute) ||
(
isStandaloneProfileRoute(settingsRoute) &&
settingsProfileUserId != null &&
settingsProfileUserId == ownUserId
)
val listPaneHazeState = rememberHazeState()
CompositionLocalProvider(
LocalPaneHazeState provides listPaneHazeState,
@@ -739,6 +750,7 @@ fun App(
)
},
)
SideEffect { detailNavGraphsReady = true }
},
)
}
@@ -753,3 +765,12 @@ fun App(
}
}
}
@Composable
private fun SettingsDetailBackStackObserver(
navController: NavHostController,
onChanged: (NavBackStackEntry?) -> Unit,
) {
val entry by navController.currentBackStackEntryAsState()
SideEffect { onChanged(entry) }
}
@@ -19,6 +19,7 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -87,6 +88,7 @@ fun PreAuthOverflowMenu() {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(Res.string.more),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(
@@ -103,6 +103,7 @@ import kotlinx.serialization.json.jsonPrimitive
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Logger
import ru.fromchat.AppForeground
import ru.fromchat.supportsMouseMessageInteraction
import ru.fromchat.ui.main.ConversationDetailContentPadding
import ru.fromchat.ui.main.LocalConversationListDetailActive
import ru.fromchat.ui.main.detailPaneShowBackButton
@@ -1145,7 +1146,7 @@ fun ChatScreen(
bridge = attachmentDropBridge,
)
.then(
if (contextMenuState.isOpen) {
if (contextMenuState.isOpen && !supportsMouseMessageInteraction()) {
Modifier.pointerInput(Unit) {
detectTapGestures {
contextMenuState =
@@ -0,0 +1,4 @@
package ru.fromchat.ui.chat
/** macOS desktop uses a separate OS window for context menus; other platforms use in-window [Popup]. */
internal expect fun usesMacOsOffscreenContextMenu(): Boolean
@@ -0,0 +1,27 @@
package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
/** In-window context menu host; menu position is clamped to the window by the caller. */
@Composable
internal fun InAppContextMenuPopup(
onDismissRequest: () -> Unit,
positionProvider: PopupPositionProvider,
reserveOvershoot: Boolean,
content: @Composable () -> Unit,
) {
Popup(
onDismissRequest = onDismissRequest,
popupPositionProvider = positionProvider,
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true,
clippingEnabled = !reserveOvershoot,
),
content = content,
)
}
@@ -70,18 +70,8 @@ import ru.fromchat.api.local.download.resolveSavableMessageFile
import ru.fromchat.api.local.download.resolveSavableMessageImage
import ru.fromchat.api.local.messages.isQueuedOutbound
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.supportsMouseMessageInteraction
import ru.fromchat.ui.components.Text
/** Platform host for the message context menu (Popup on mobile, OS window on desktop). */
@Composable
internal expect fun MessageContextMenuPopup(
onDismissRequest: () -> Unit,
positionProvider: PopupPositionProvider,
reserveOvershoot: Boolean = false,
content: @Composable () -> Unit,
)
data class ContextMenuState(
val isOpen: Boolean = false,
val message: Message? = null,
@@ -116,7 +106,7 @@ private val contextMenuEnterSpec = spring<Float>(
stiffness = Spring.StiffnessMediumLow,
)
private fun clampContextMenuOffset(
internal fun clampContextMenuOffset(
position: IntOffset,
popupSize: IntSize,
screenWidthPx: Int,
@@ -173,6 +163,7 @@ fun MessageContextMenu(
mutableStateOf(TransformOrigin(0f, 0f))
}
var lockedMenuWidthPx by remember(state.message) { mutableIntStateOf(0) }
val offscreenMacMenu = usesMacOsOffscreenContextMenu()
LaunchedEffect(state.isOpen) {
if (!state.isOpen) {
@@ -214,7 +205,6 @@ fun MessageContextMenu(
if (shouldShowPopup && state.message != null) {
val density = LocalDensity.current
val paddingPx = with(density) { 16.dp.toPx().toInt() }
val allowOutsideWindow = supportsMouseMessageInteraction()
// Clamp with popupContentSize — do not SubcomposeLayout-measure under
// SharedTransitionLayout (writes state during LookaheadMeasuring).
@@ -223,7 +213,6 @@ fun MessageContextMenu(
screenWidthPx,
screenHeightPx,
paddingPx,
allowOutsideWindow,
) {
object : PopupPositionProvider {
override fun calculatePosition(
@@ -237,23 +226,22 @@ fun MessageContextMenu(
screenWidthPx = screenWidthPx,
screenHeightPx = screenHeightPx,
paddingPx = paddingPx,
allowOutsideWindow = allowOutsideWindow,
allowOutsideWindow = offscreenMacMenu,
)
}
}
// Match list-item menus (0.5→1) so spring overshoot past 1 is visible.
val scale = 0.5f + 0.5f * animationProgress.floatValue
val alpha = animationProgress.floatValue.coerceIn(0f, 1f)
// [state.position] is window coordinates (see MessageItem localToWindow).
// Alignment+offset would add the popup parents window origin again (double offset
// in listdetail panes). Place with an absolute provider instead.
// Desktop hosts this in a transparent OS window so the menu can leave the app frame.
MessageContextMenuPopup(
onDismissRequest = onDismiss,
positionProvider = positionProvider,
reserveOvershoot = reserveOvershoot,
deferReveal = !offscreenMacMenu || enterLaidOut,
) {
ContextMenuContent(
message = state.message,
@@ -306,7 +294,7 @@ fun MessageContextMenu(
screenWidthPx = screenWidthPx,
screenHeightPx = screenHeightPx,
paddingPx = paddingPx,
allowOutsideWindow = allowOutsideWindow,
allowOutsideWindow = offscreenMacMenu,
)
frozenOrigin = TransformOrigin(
pivotFractionX = ((state.position.x - adjusted.x).toFloat() / size.width)
@@ -0,0 +1,18 @@
package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.PopupPositionProvider
/**
* Platform host for the message context menu.
* macOS desktop: separate OS window. Everything else: in-window [Popup].
*/
@Composable
internal expect fun MessageContextMenuPopup(
onDismissRequest: () -> Unit,
positionProvider: PopupPositionProvider,
reserveOvershoot: Boolean = false,
/** macOS OS popup only: defer reveal until first layout. */
deferReveal: Boolean = true,
content: @Composable () -> Unit,
)
@@ -32,7 +32,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -62,7 +62,7 @@ object AttachmentDragSession {
/**
* Exactly one drop target may show the blur/scrim at a time. Hover is resolved by
* hit-testing registered window bounds against the current pointer so nested AWT
* hit-testing registered root bounds against the current pointer so nested AWT
* targets cannot leave a stale row blurred.
*/
object AttachmentDropHighlight {
@@ -260,7 +260,7 @@ fun Modifier.chatAttachmentDropTarget(
}
}
onGloballyPositioned { coords ->
AttachmentDropHighlight.register(bridge, coords.boundsInWindow())
AttachmentDropHighlight.register(bridge, coords.boundsInRoot())
}.dragAndDropTarget(
shouldStartDragAndDrop = { acceptsAttachmentDrop(it) },
target = target,
@@ -91,19 +91,7 @@ fun DesktopSettingsDetailNavHost(
onOpenChatFromProfile: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val detailRoute =
navController.currentBackStackEntryAsState().value?.destination?.route
val showPanel =
detailRoute != null && detailRoute != DesktopDetailNav.SETTINGS_ROOT
Box(modifier.fillMaxSize()) {
if (showPanel) {
Box(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
)
}
CompositionLocalProvider(LocalNavController provides navController) {
NavHost(
navController = navController,
@@ -128,6 +116,17 @@ fun DesktopSettingsDetailNavHost(
)
}
}
val detailRoute =
navController.currentBackStackEntryAsState().value?.destination?.route
val showPanel =
detailRoute != null && detailRoute != DesktopDetailNav.SETTINGS_ROOT
if (showPanel) {
Box(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
)
}
}
}
@@ -0,0 +1,3 @@
package ru.fromchat.ui.chat
internal actual fun usesMacOsOffscreenContextMenu(): Boolean = false
@@ -1,26 +1,21 @@
package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
@Composable
@Suppress("UNUSED_PARAMETER")
internal actual fun MessageContextMenuPopup(
onDismissRequest: () -> Unit,
positionProvider: PopupPositionProvider,
reserveOvershoot: Boolean,
deferReveal: Boolean,
content: @Composable () -> Unit,
) {
Popup(
InAppContextMenuPopup(
onDismissRequest = onDismissRequest,
popupPositionProvider = positionProvider,
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true,
clippingEnabled = !reserveOvershoot,
),
positionProvider = positionProvider,
reserveOvershoot = reserveOvershoot,
content = content,
)
}
@@ -0,0 +1,51 @@
package ru.fromchat.ui
import java.awt.Rectangle
import java.awt.Window
import java.awt.event.MouseEvent
import javax.swing.JFrame
import javax.swing.SwingUtilities
/** Clears AWT window chrome for transparent Compose popups (macOS context menus). */
internal fun applyTransparentComposeWindow(window: Window) {
window.background = java.awt.Color(0, 0, 0, 0)
val frame = window as? JFrame ?: return
frame.contentPane.background = java.awt.Color(0, 0, 0, 0)
}
/** Dismiss after the opening click releases; ignore presses inside the popup itself. */
internal fun installOsPopupDismissOnClickOutside(
popupWindow: Window,
onDismiss: () -> Unit,
): () -> Unit {
val toolkit = java.awt.Toolkit.getDefaultToolkit()
var armed = false
val armTimer = javax.swing.Timer(250) { armed = true }.apply {
isRepeats = false
start()
}
val listener = java.awt.event.AWTEventListener { event ->
when (event.id) {
java.awt.event.MouseEvent.MOUSE_RELEASED -> armed = true
java.awt.event.MouseEvent.MOUSE_PRESSED -> {
if (!armed) return@AWTEventListener
val mouse = event as java.awt.event.MouseEvent
if (mouse.button != MouseEvent.BUTTON1) return@AWTEventListener
if (mouse.isInsideWindow(popupWindow)) return@AWTEventListener
SwingUtilities.invokeLater(onDismiss)
}
}
}
toolkit.addAWTEventListener(listener, java.awt.AWTEvent.MOUSE_EVENT_MASK)
return {
armTimer.stop()
toolkit.removeAWTEventListener(listener)
}
}
/** Returns true when [event] landed inside [window]'s screen bounds. */
fun MouseEvent.isInsideWindow(window: Window): Boolean {
val origin = window.locationOnScreen
val bounds = Rectangle(origin.x, origin.y, window.width, window.height)
return bounds.contains(locationOnScreen)
}
@@ -0,0 +1,194 @@
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
package ru.fromchat.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.LocalAwtWindow
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.rememberWindowState
import java.awt.Point
import kotlin.math.roundToInt
/** macOS-only separate undecorated OS popup for context menus. */
@Composable
internal fun MacOsContextMenuPopupWindow(
onDismissRequest: () -> Unit,
positionProvider: PopupPositionProvider,
revealWindow: Boolean,
content: @Composable () -> Unit,
) {
val ownerWindow = LocalAwtWindow.current
val composeContainer = LocalWindowInfo.current.containerSize
val density = LocalDensity.current
val dismiss = rememberUpdatedState(onDismissRequest)
var contentSizePx by remember { mutableStateOf(IntSize.Zero) }
val windowState = rememberWindowState(
width = 1.dp,
height = 1.dp,
)
Window(
onCloseRequest = { dismiss.value() },
state = windowState,
title = "",
undecorated = true,
transparent = true,
alwaysOnTop = true,
resizable = false,
focusable = false,
onPreviewKeyEvent = { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) {
dismiss.value()
true
} else {
false
}
},
) {
val awtWindow = window
DisposableEffect(awtWindow, ownerWindow) {
applyTransparentComposeWindow(awtWindow)
awtWindow.isVisible = false
val disposeDismiss = installOsPopupDismissOnClickOutside(
popupWindow = awtWindow,
onDismiss = { dismiss.value() },
)
onDispose {
awtWindow.isVisible = false
disposeDismiss()
ownerWindow?.let { owner ->
if (owner.isShowing) {
owner.requestFocus()
}
}
}
}
LaunchedEffect(revealWindow, contentSizePx, ownerWindow, composeContainer) {
if (!revealWindow || contentSizePx.width <= 0 || contentSizePx.height <= 0) {
awtWindow.isVisible = false
return@LaunchedEffect
}
val anchor = popupScreenAnchor(
owner = ownerWindow,
composeContainer = composeContainer,
positionProvider = positionProvider,
popupSize = contentSizePx,
)
with(density) {
windowState.size = DpSize(
width = contentSizePx.width.toDp(),
height = contentSizePx.height.toDp(),
)
windowState.position = WindowPosition(
x = (anchor.x / density.density).dp,
y = (anchor.y / density.density).dp,
)
}
applyAwtPopupBounds(
window = awtWindow,
anchor = anchor,
contentSizePx = contentSizePx,
density = density.density,
)
awtWindow.isVisible = true
}
Box(
modifier = Modifier
.wrapContentSize()
.onSizeChanged { size ->
if (size.width > 0 && size.height > 0) {
contentSizePx = size
}
},
) {
FromChatTheme { content() }
}
}
}
private fun applyAwtPopupBounds(
window: java.awt.Window,
anchor: Point,
contentSizePx: IntSize,
density: Float,
) {
val insets = window.insets
val width = (contentSizePx.width / density).roundToInt() +
insets.left + insets.right
val height = (contentSizePx.height / density).roundToInt() +
insets.top + insets.bottom
val awtWidth = width.coerceAtLeast(1)
val awtHeight = height.coerceAtLeast(1)
if (window.width != awtWidth || window.height != awtHeight) {
window.setSize(awtWidth, awtHeight)
}
window.location = clampPopupToScreen(
anchor,
awtWidth,
awtHeight,
window.graphicsConfiguration.bounds,
)
}
private fun popupScreenAnchor(
owner: java.awt.Window?,
composeContainer: IntSize,
positionProvider: PopupPositionProvider,
popupSize: IntSize,
): Point {
if (owner == null || composeContainer.width <= 0 || composeContainer.height <= 0) {
return java.awt.MouseInfo.getPointerInfo()?.location ?: Point(80, 80)
}
val ownerLocation = owner.locationOnScreen
val composeOffset = positionProvider.calculatePosition(
anchorBounds = IntRect.Zero,
windowSize = composeContainer,
layoutDirection = LayoutDirection.Ltr,
popupContentSize = popupSize,
)
val width = composeContainer.width.coerceAtLeast(1)
val height = composeContainer.height.coerceAtLeast(1)
return Point(
ownerLocation.x + composeOffset.x * owner.width / width,
ownerLocation.y + composeOffset.y * owner.height / height,
)
}
private fun clampPopupToScreen(
anchor: Point,
width: Int,
height: Int,
screen: java.awt.Rectangle,
): Point {
val x = anchor.x.coerceIn(screen.x, (screen.x + screen.width - width).coerceAtLeast(screen.x))
val y = anchor.y.coerceIn(screen.y, (screen.y + screen.height - height).coerceAtLeast(screen.y))
return Point(x, y)
}
@@ -28,17 +28,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.action_copy
import ru.fromchat.ui.MacOsContextMenuPopupWindow
import androidx.compose.foundation.ContextMenuState as FoundationContextMenuState
@OptIn(ExperimentalFoundationApi::class)
@@ -71,7 +71,6 @@ private class ChatStyleTextContextMenu(
)
},
state = state,
// Empty selection: let secondary click reach the message menu handler.
enabled = textManager.selectedText.isNotEmpty(),
content = content,
)
@@ -99,8 +98,11 @@ private object ChatStyleTextContextMenuRepresentation : ContextMenuRepresentatio
)
val animationProgress = remember { mutableFloatStateOf(0f) }
var enterLaidOut by remember { mutableStateOf(false) }
val offscreenMacMenu = usesMacOsOffscreenContextMenu()
val density = LocalDensity.current
val paddingPx = with(density) { 16.dp.toPx().toInt() }
LaunchedEffect(enterLaidOut) {
LaunchedEffect(status.rect, enterLaidOut) {
if (!enterLaidOut) {
animationProgress.floatValue = 0f
return@LaunchedEffect
@@ -118,36 +120,36 @@ private object ChatStyleTextContextMenuRepresentation : ContextMenuRepresentatio
}
}
val positionProvider = remember(anchor) {
val positionProvider = remember(anchor, paddingPx, offscreenMacMenu) {
object : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset = anchor
): IntOffset = if (offscreenMacMenu) {
anchor
} else {
clampContextMenuOffset(
position = anchor,
popupSize = popupContentSize,
screenWidthPx = windowSize.width,
screenHeightPx = windowSize.height,
paddingPx = paddingPx,
allowOutsideWindow = false,
)
}
}
}
Popup(
popupPositionProvider = positionProvider,
onDismissRequest = {
state.status = FoundationContextMenuState.Status.Closed
},
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true,
clippingEnabled = false,
),
) {
val menuContent: @Composable () -> Unit = {
ChatStyleContextMenuFrame(
modifier = Modifier.onSizeChanged { size ->
if (size.width > 0 && size.height > 0) enterLaidOut = true
},
animated = true,
scale = 0.8f + 0.2f * animationProgress.floatValue,
alpha = animationProgress.floatValue,
scale = 0.5f + 0.5f * animationProgress.floatValue,
alpha = animationProgress.floatValue.coerceIn(0f, 1f),
transformOriginX = 0f,
transformOriginY = 0f,
) {
@@ -171,5 +173,25 @@ private object ChatStyleTextContextMenuRepresentation : ContextMenuRepresentatio
}
}
}
if (offscreenMacMenu) {
MacOsContextMenuPopupWindow(
onDismissRequest = {
state.status = FoundationContextMenuState.Status.Closed
},
positionProvider = positionProvider,
revealWindow = enterLaidOut,
content = menuContent,
)
} else {
InAppContextMenuPopup(
onDismissRequest = {
state.status = FoundationContextMenuState.Status.Closed
},
positionProvider = positionProvider,
reserveOvershoot = false,
content = menuContent,
)
}
}
}
@@ -0,0 +1,4 @@
package ru.fromchat.ui.chat
internal actual fun usesMacOsOffscreenContextMenu(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("mac")
@@ -1,213 +1,30 @@
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
package ru.fromchat.ui.chat
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.LocalAwtWindow
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.rememberWindowState
import java.awt.AWTEvent
import java.awt.Point
import java.awt.Toolkit
import java.awt.event.AWTEventListener
import java.awt.event.KeyEvent
import java.awt.event.MouseEvent
import javax.swing.RootPaneContainer
import javax.swing.SwingUtilities
import ru.fromchat.ui.MacOsContextMenuPopupWindow
/** Extra OS-window size so the LowBouncy scale can overshoot the laid-out menu. */
private const val MenuOvershootReserve = 0.15f
/**
* Desktop message menus use a real OS [Window] so they can hang outside the main app frame.
* The window is not focusable so opening it does not defocus the chat. The opening right-click
* is ignored until it is released; later presses outside the menu dismiss it.
*/
@Composable
internal actual fun MessageContextMenuPopup(
onDismissRequest: () -> Unit,
positionProvider: PopupPositionProvider,
reserveOvershoot: Boolean,
deferReveal: Boolean,
content: @Composable () -> Unit,
) {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val containerSize = LocalWindowInfo.current.containerSize
val parentWindow = LocalAwtWindow.current
val onDismiss by rememberUpdatedState(onDismissRequest)
var menuSize by remember { mutableStateOf(IntSize.Zero) }
val positionInWindow = remember(positionProvider, menuSize, containerSize, layoutDirection) {
positionProvider.calculatePosition(
anchorBounds = IntRect.Zero,
windowSize = containerSize,
layoutDirection = layoutDirection,
popupContentSize = menuSize,
if (usesMacOsOffscreenContextMenu()) {
MacOsContextMenuPopupWindow(
onDismissRequest = onDismissRequest,
positionProvider = positionProvider,
revealWindow = deferReveal,
content = content,
)
} else {
InAppContextMenuPopup(
onDismissRequest = onDismissRequest,
positionProvider = positionProvider,
reserveOvershoot = reserveOvershoot,
content = content,
)
}
val positionOnScreen = remember(positionInWindow, parentWindow, density) {
composeOffsetToScreen(parentWindow, positionInWindow, density)
}
val shadowPad = 12.dp
val overshootPad = if (reserveOvershoot && menuSize != IntSize.Zero) {
with(density) {
DpSize(
(menuSize.width * MenuOvershootReserve).toDp(),
(menuSize.height * MenuOvershootReserve).toDp(),
)
}
} else {
DpSize(0.dp, 0.dp)
}
val windowSize = if (menuSize == IntSize.Zero) {
// Compose Desktop cannot actualize Unspecified window sizes.
DpSize(360.dp, 560.dp)
} else {
with(density) {
DpSize(
menuSize.width.toDp() + shadowPad * 2 + overshootPad.width,
menuSize.height.toDp() + shadowPad * 2 + overshootPad.height,
)
}
}
val windowPosition = WindowPosition(
x = positionOnScreen.x - shadowPad,
y = positionOnScreen.y - shadowPad,
)
val windowState = rememberWindowState(
position = windowPosition,
size = windowSize,
)
SideEffect {
if (windowState.position != windowPosition) windowState.position = windowPosition
if (windowState.size != windowSize) windowState.size = windowSize
}
Window(
onCloseRequest = onDismiss,
state = windowState,
title = "",
undecorated = true,
transparent = true,
resizable = false,
alwaysOnTop = true,
focusable = false,
) {
val menuWindow = window
DisposableEffect(menuWindow) {
menuWindow.isAlwaysOnTop = true
onDispose { }
}
DisposableEffect(menuWindow, onDismiss) {
val toolkit = Toolkit.getDefaultToolkit()
var armed = false
val listener = AWTEventListener { awtEvent ->
when (awtEvent.id) {
MouseEvent.MOUSE_RELEASED -> armed = true
MouseEvent.MOUSE_PRESSED -> {
if (!armed) return@AWTEventListener
val mouse = awtEvent as MouseEvent
val sourceWindow = SwingUtilities.getWindowAncestor(mouse.component)
?: mouse.component as? java.awt.Window
if (sourceWindow !== menuWindow) {
onDismiss()
}
}
KeyEvent.KEY_PRESSED -> {
if ((awtEvent as KeyEvent).keyCode == KeyEvent.VK_ESCAPE) {
onDismiss()
}
}
}
}
toolkit.addAWTEventListener(
listener,
AWTEvent.MOUSE_EVENT_MASK or AWTEvent.KEY_EVENT_MASK,
)
onDispose { toolkit.removeAWTEventListener(listener) }
}
Box(Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.align(Alignment.TopStart)
.padding(shadowPad),
) {
Box(
// Unbounded width only — verticalScroll rejects infinite max height.
modifier = Modifier
.wrapContentWidth(unbounded = true)
.onSizeChanged { size ->
if (size.width <= 0 || size.height <= 0) return@onSizeChanged
// Grow-only: a lagging OS resize must not stick a squeezed width
// for the rest of the spring.
if (
menuSize == IntSize.Zero ||
size.width > menuSize.width ||
size.height > menuSize.height
) {
menuSize = IntSize(
maxOf(menuSize.width, size.width),
maxOf(menuSize.height, size.height),
)
}
},
) {
content()
}
}
}
}
}
/**
* Compose Desktop [WindowPosition] maps `Dp.value` 1:1 onto AWT screen pixels. Compose
* [IntOffset]s from `localToWindow` are density-scaled pixels, so they must be converted
* with [androidx.compose.ui.unit.Density] instead of added to the AWT origin first.
*/
private fun composeOffsetToScreen(
parentWindow: java.awt.Window?,
offsetInWindow: IntOffset,
density: Density,
): WindowPosition {
val origin = parentWindow?.composeLocalOriginOnScreen() ?: Point()
return WindowPosition(
x = origin.x.dp + with(density) { offsetInWindow.x.toDp() },
y = origin.y.dp + with(density) { offsetInWindow.y.toDp() },
)
}
private fun java.awt.Window.composeLocalOriginOnScreen(): Point {
val root = (this as? RootPaneContainer)?.contentPane ?: this
return runCatching {
Point(0, 0).also { SwingUtilities.convertPointToScreen(it, root) }
}.getOrElse {
runCatching { locationOnScreen }.getOrDefault(Point())
}
}
@@ -8,10 +8,15 @@ import androidx.compose.ui.draganddrop.DragData
import androidx.compose.ui.draganddrop.awtTransferable
import androidx.compose.ui.draganddrop.dragData
import androidx.compose.ui.geometry.Offset
import java.awt.MouseInfo
import java.awt.Point
import java.awt.datatransfer.DataFlavor
import java.awt.dnd.DropTargetDragEvent
import java.awt.dnd.DropTargetDropEvent
import java.awt.dnd.DropTargetEvent
import java.io.File
import java.net.URI
import javax.swing.RootPaneContainer
import javax.swing.SwingUtilities
actual class AttachmentDropPermissionsHost
@@ -20,12 +25,40 @@ actual fun rememberAttachmentDropPermissionsHost(): AttachmentDropPermissionsHos
AttachmentDropPermissionsHost()
actual fun dragPointerInWindow(event: DragAndDropEvent): Offset? {
val pointer = MouseInfo.getPointerInfo()?.location ?: return null
val window = java.awt.KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow
?: java.awt.Window.getWindows().firstOrNull { it.isShowing }
?: return null
val origin = runCatching { window.locationOnScreen }.getOrNull() ?: return null
return Offset((pointer.x - origin.x).toFloat(), (pointer.y - origin.y).toFloat())
readComposePositionInRoot(event)?.let { return it }
val awtEvent = event.nativeEvent as? DropTargetEvent ?: return null
val location = when (awtEvent) {
is DropTargetDragEvent -> awtEvent.location
is DropTargetDropEvent -> awtEvent.location
else -> return null
}
val component = awtEvent.dropTargetContext?.component ?: return null
val window = SwingUtilities.getWindowAncestor(component) ?: return null
val root = (window as? RootPaneContainer)?.contentPane ?: component
val point = Point(location)
SwingUtilities.convertPoint(component, point, root)
return awtClientPointToComposeRoot(window, point)
}
private fun readComposePositionInRoot(event: DragAndDropEvent): Offset? {
return runCatching {
val field = DragAndDropEvent::class.java.getDeclaredField("positionInRootImpl")
field.isAccessible = true
val offset = field.get(event) as Offset
if (offset.x.isFinite() && offset.y.isFinite()) offset else null
}.getOrNull()
}
/**
* AWT client coordinates on HiDPI displays are often in user space while
* [androidx.compose.ui.layout.boundsInRoot] uses Compose layout pixels.
*/
private fun awtClientPointToComposeRoot(window: java.awt.Window, point: Point): Offset {
val transform = window.graphicsConfiguration.defaultTransform
val scaleX = transform.scaleX.toFloat().coerceAtLeast(1f)
val scaleY = transform.scaleY.toFloat().coerceAtLeast(1f)
return Offset(point.x * scaleX, point.y * scaleY)
}
actual fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean {
@@ -60,7 +93,6 @@ actual fun handleAttachmentDrop(
}
private fun extractAttachmentDropUris(event: DragAndDropEvent): List<String> {
// Prefer Compose's AWT helper — returns file:// URIs for javaFileListFlavor.
when (val data = event.dragData()) {
is DragData.FilesList -> {
val paths = data.readFiles().mapNotNull { uriStringToLocalPath(it) }.distinct()
+31 -6
View File
@@ -8,12 +8,9 @@ if /I "%1"=="sync" (
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%"
call "%~dp0ensure-windows-arm64-jdk.cmd"
if errorlevel 1 exit /b 1
set "FROMCHAT_PACKAGING_JDK=%JAVA_HOME%"
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
@@ -26,6 +23,31 @@ if /I "%1"=="rust" (
exit /b %ERRORLEVEL%
)
if /I "%1"=="beta" (
call "%~dp0sync-windows-vm.cmd" all
if errorlevel 1 exit /b 1
if exist gradle\gradle-daemon-jvm.properties del /f gradle\gradle-daemon-jvm.properties
if /I "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
if exist app\desktop\build\compose\tmp\main\runtime rmdir /s /q app\desktop\build\compose\tmp\main\runtime
if exist app\desktop\build\compose\binaries\main\app rmdir /s /q app\desktop\build\compose\binaries\main\app
if not exist app\desktop\build\prebuilt\windows-x64\app\FromChat\FromChat.exe (
echo WARNING: No x64 prebuilt at app\desktop\build\prebuilt\windows-x64\app\FromChat
echo Installer will be ARM64-only. Run scripts\stage-windows-x64-prebuilt.cmd first.
)
)
gradlew.bat :app:desktop:packageBetaWindows -PbetaDesktop --no-daemon
if errorlevel 1 exit /b 1
call "%~dp0copy-installers-to-desktop.cmd" beta
exit /b %ERRORLEVEL%
)
if /I "%1"=="install" (
call "%~dp0build-windows-vm.cmd" beta
if errorlevel 1 exit /b 1
call "%~dp0run-beta-installer.cmd"
exit /b %ERRORLEVEL%
)
if /I "%1"=="pack" (
call "%~dp0sync-windows-vm.cmd" rust
if errorlevel 1 exit /b 1
@@ -48,6 +70,9 @@ if /I "%1"=="gradle" (
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 beta ^(debug app + beta setup EXE, no ProGuard^)
echo build-windows-vm.cmd install ^(beta build + upgrade install + launch^)
echo Place x64 app-image: scripts\stage-windows-x64-prebuilt.cmd ^<path^>
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
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="${1:?usage: ci-set-version.sh <version>}"
VERSION_CODE="$(printf '%s' "$VERSION" | sed 's/[^0-9]//g')"
if [[ -z "$VERSION_CODE" ]]; then
VERSION_CODE=1
fi
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
GRADLE_FILE="$ROOT/build.gradle.kts"
sed -i.bak "s/extra\\[\"versionName\"\\] = \"[^\"]*\"/extra[\"versionName\"] = \"$VERSION\"/" "$GRADLE_FILE"
sed -i.bak "s/extra\\[\"versionCode\"\\] = [0-9]*/extra[\"versionCode\"] = $VERSION_CODE/" "$GRADLE_FILE"
rm -f "$GRADLE_FILE.bak"
echo "versionName=$VERSION versionCode=$VERSION_CODE"
+47 -7
View File
@@ -3,10 +3,13 @@ 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%%.
rem
rem copy-installers-to-desktop.cmd — release setup only
rem copy-installers-to-desktop.cmd beta — beta2 setup only (cleans desktop)
set "DIST=C:\FromChat\app\app\desktop\build\distributions\release"
set "WIN_DESKTOP="
set "COPIED=0"
set "MODE=%~1"
if not exist "%DIST%\" (
echo WARNING: Release output folder missing: %DIST%
@@ -34,6 +37,23 @@ if not defined WIN_DESKTOP (
call :kill_running_installers
if /I "%MODE%"=="beta" (
call :clean_desktop_installers
set "COPIED=0"
for %%F in ("%DIST%\FromChat-Setup-*-beta2.exe") do (
if exist "%%F" (
call :copy_installer "%%~fF" "%WIN_DESKTOP%"
if errorlevel 1 exit /b 1
set "COPIED=1"
)
)
if "!COPIED!"=="0" (
echo ERROR: No FromChat-Setup-*-beta2.exe found in %DIST%
exit /b 1
)
exit /b 0
)
for %%F in ("%DIST%\FromChat-Portable-*.exe") do (
if exist "%%F" (
del /F /Q "%%F" >nul 2>&1
@@ -48,24 +68,44 @@ for %%F in ("%WIN_DESKTOP%\FromChat-Portable-*.exe") do (
)
)
set "COPIED=0"
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"
echo %%~nxF | findstr /i /c:"beta" >nul
if errorlevel 1 (
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%
echo WARNING: No release FromChat-Setup-*.exe found in %DIST%
)
exit /b 0
:clean_desktop_installers
echo Cleaning Desktop installer clutter...
for %%F in ("%WIN_DESKTOP%\FromChat-Setup*.exe") do (
if exist "%%F" (
del /F /Q "%%F" >nul 2>&1
echo Removed %%~nxF
)
)
for %%F in ("%WIN_DESKTOP%\FromChat-Portable*.exe") do (
if exist "%%F" (
del /F /Q "%%F" >nul 2>&1
echo Removed %%~nxF
)
)
exit /b 0
:kill_running_installers
echo Stopping running FromChat installer processes...
powershell -NoProfile -Command ^
"$names = @('fromchat-setup','FromChat-Setup*','FromChat-Portable*');" ^
"$names = @('fromchat-setup','FromChat-Installer','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
+68
View File
@@ -0,0 +1,68 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
rem Ensure a native Windows ARM64 JDK is available for Gradle/jpackage builds.
rem x64 JDKs under emulation package x64 Skiko/JavaFX natives and break the desktop app.
if /I not "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
if /I not "%PROCESSOR_ARCHITEW6432%"=="ARM64" (
exit /b 0
)
)
set "JDK_ROOT=C:\Program Files\Eclipse Adoptium"
set "JDK_HOME="
set "JDK_FALLBACK=%LOCALAPPDATA%\Programs\Eclipse Adoptium\jdk-21-arm64"
for /d %%D in ("%JDK_ROOT%\jdk-*-arm64") do (
if exist "%%D\bin\java.exe" set "JDK_HOME=%%D"
)
for /d %%D in ("%JDK_ROOT%\jdk-*aarch64*") do (
if exist "%%D\bin\java.exe" set "JDK_HOME=%%D"
)
if not defined JDK_HOME (
for /d %%D in ("%JDK_ROOT%\jdk-*") do (
if not defined JDK_HOME if exist "%%D\bin\java.exe" (
"%%D\bin\java.exe" -XshowSettings:properties -version 2>&1 | findstr /i /c:"os.arch = aarch64" >nul
if not errorlevel 1 set "JDK_HOME=%%D"
)
)
)
if defined JDK_HOME goto :verify
echo Installing Temurin JDK 21 for Windows ARM64...
set "TMP_ZIP=%TEMP%\fromchat-temurin21-arm64.zip"
set "TMP_DIR=%TEMP%\fromchat-temurin21-arm64"
set "JDK_HOME=%JDK_ROOT%\jdk-21.0.12.101-hotspot-arm64"
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"$zip='%TMP_ZIP%'; $tmp='%TMP_DIR%'; $dest='%JDK_HOME%'; $fallback='%JDK_FALLBACK%';" ^
"try { New-Item -ItemType Directory -Force -Path $dest | Out-Null } catch { $dest = $fallback; New-Item -ItemType Directory -Force -Path $dest | Out-Null };" ^
"Invoke-WebRequest -Uri 'https://api.adoptium.net/v3/binary/latest/21/ga/windows/aarch64/jdk/hotspot/normal/eclipse?project=jdk' -OutFile $zip;" ^
"if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp };" ^
"Expand-Archive -Path $zip -DestinationPath $tmp -Force;" ^
"$inner = Get-ChildItem $tmp -Directory | Select-Object -First 1;" ^
"Copy-Item -Path (Join-Path $inner.FullName '*') -Destination $dest -Recurse -Force"
if not exist "%JDK_HOME%\bin\java.exe" set "JDK_HOME=%JDK_FALLBACK%"
if not exist "%JDK_HOME%\bin\java.exe" (
echo ERROR: Failed to install ARM64 JDK under %JDK_ROOT%
exit /b 1
)
:verify
"%JDK_HOME%\bin\java.exe" -XshowSettings:properties -version 2>&1 | findstr /i /c:"os.arch = aarch64" >nul
if errorlevel 1 (
echo ERROR: %JDK_HOME% is not an ARM64 JDK ^(os.arch must be aarch64^).
exit /b 1
)
set "JAVA_HOME=%JDK_HOME%"
set "FROMCHAT_PACKAGING_JDK=%JDK_HOME%"
set "PATH=%JDK_HOME%\bin;%PATH%"
echo Using ARM64 JDK: %JDK_HOME%
endlocal & set "JAVA_HOME=%JAVA_HOME%" & set "FROMCHAT_PACKAGING_JDK=%FROMCHAT_PACKAGING_JDK%" & set "PATH=%PATH%"
exit /b 0
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="${1:?usage: package-ios-ipa.sh <version> <output-dir>}"
OUT_DIR="${2:?usage: package-ios-ipa.sh <version> <output-dir>}"
VERSION_CODE="$(printf '%s' "$VERSION" | sed 's/[^0-9]//g')"
[[ -z "$VERSION_CODE" ]] && VERSION_CODE=1
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
IOS_PROJECT_DIR="$ROOT/app/ios"
BUILD_DIR="$ROOT/build/ios"
PLIST_PATH="$IOS_PROJECT_DIR/iosApp/Info.plist"
IPA_NAME="FromChat-$VERSION-ios.ipa"
IPA_PATH="$OUT_DIR/$IPA_NAME"
export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}"
if [[ ! -d "$DEVELOPER_DIR" ]]; then
DEVELOPER_DIR="$(xcode-select -p)"
export DEVELOPER_DIR
fi
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION_CODE" "$PLIST_PATH" || true
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST_PATH" || true
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR" "$OUT_DIR"
xcodebuild \
-project "$IOS_PROJECT_DIR/iosApp.xcodeproj" \
-scheme iosApp \
-configuration Release \
-sdk iphoneos \
-destination 'generic/platform=iOS' \
-derivedDataPath "$BUILD_DIR" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
clean build
APP_BUNDLE_PATH="$(find "$BUILD_DIR/Build/Products/Release-iphoneos" -name '*.app' -type d | head -n 1)"
if [[ -z "$APP_BUNDLE_PATH" ]]; then
echo "iOS .app bundle not found" >&2
exit 1
fi
PAYLOAD_STAGE="$BUILD_DIR/ipa_stage"
rm -rf "$PAYLOAD_STAGE"
mkdir -p "$PAYLOAD_STAGE/Payload"
cp -R "$APP_BUNDLE_PATH" "$PAYLOAD_STAGE/Payload/"
( cd "$PAYLOAD_STAGE" && zip -qr "$IPA_PATH" Payload )
rm -rf "$PAYLOAD_STAGE"
git -C "$ROOT" checkout -- "$PLIST_PATH" >/dev/null 2>&1 || true
echo "Wrote $IPA_PATH"
+54
View File
@@ -0,0 +1,54 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
set "WIN_DESKTOP="
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 Desktop path
exit /b 1
)
set "INSTALLER="
for /f "delims=" %%F in ('dir /b /od "%WIN_DESKTOP%\FromChat-Setup-*-beta2.exe" 2^>nul') do set "INSTALLER=%WIN_DESKTOP%\%%F"
if not defined INSTALLER (
set "DIST=C:\FromChat\app\app\desktop\build\distributions\release"
for /f "delims=" %%F in ('dir /b /od "%DIST%\FromChat-Setup-*-beta2.exe" 2^>nul') do set "INSTALLER=%DIST%\%%F"
)
if not exist "%INSTALLER%" (
echo ERROR: No FromChat-Setup-*-beta2.exe on Desktop or in dist folder
exit /b 1
)
echo Launching installer: %INSTALLER%
set "TASK=FromChatBetaInstall"
schtasks /delete /tn "%TASK%" /f >nul 2>&1
for /f "tokens=1-2 delims=:" %%H in ("%TIME%") do set "ST=%%H:%%M"
set "ST=%ST: =0%"
if defined LOGON (
schtasks /create /tn "%TASK%" /tr "\"%INSTALLER%\" --upgrade --launch" /sc once /st %ST% /ru "%LOGON%" /it /f
) else (
schtasks /create /tn "%TASK%" /tr "\"%INSTALLER%\" --upgrade --launch" /sc once /st %ST% /f
)
schtasks /run /tn "%TASK%" >nul
ping -n 3 127.0.0.1 >nul
schtasks /delete /tn "%TASK%" /f >nul 2>&1
echo Installer started.
exit /b 0
+19
View File
@@ -0,0 +1,19 @@
@echo off
setlocal EnableExtensions
if "%~1"=="" (
echo Usage: stage-windows-x64-prebuilt.cmd ^<path-to-FromChat-app-image^>
echo Example: stage-windows-x64-prebuilt.cmd C:\Downloads\FromChat
exit /b 1
)
set "SRC=%~1"
if not exist "%SRC%\FromChat.exe" (
echo Expected FromChat.exe in %SRC%
exit /b 1
)
cd /d "%~dp0.."
set "DEST=app\desktop\build\prebuilt\windows-x64\app\FromChat"
if exist app\desktop\build\prebuilt\windows-x64 rmdir /s /q app\desktop\build\prebuilt\windows-x64
mkdir "%DEST%"
xcopy /E /I /Y "%SRC%\*" "%DEST%\"
echo Staged x64 app image at %DEST%
exit /b 0
@@ -6,7 +6,7 @@ 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`
* - Windows installed: `%LOCALAPPDATA%\FromChat` (or `FromChat Beta` when `-Dfromchat.app.data.name` is set)
* - macOS: `~/Library/Application Support/FromChat`
* - Linux: `${XDG_DATA_HOME:-~/.local/share}/FromChat`
*
@@ -63,24 +63,34 @@ internal object AppDataRoot {
return File(System.getProperty("user.dir") ?: ".").absoluteFile
}
private fun installedAppDirName(): String {
val raw = System.getProperty("fromchat.app.data.name")?.trim()?.takeIf { it.isNotEmpty() }
return when (raw) {
"FromChatBeta" -> "FromChat Beta"
else -> raw ?: APP_DIR_NAME
}
}
private fun windowsInstalledRoot(): File {
val dirName = installedAppDirName()
val localAppData = System.getenv("LOCALAPPDATA")?.takeIf { it.isNotBlank() }
return if (localAppData != null) {
File(localAppData, APP_DIR_NAME)
File(localAppData, dirName)
} else {
File(requireHome(), APP_DIR_NAME)
File(requireHome(), dirName)
}
}
private fun macInstalledRoot(): File =
File(requireHome(), "Library/Application Support/$APP_DIR_NAME")
File(requireHome(), "Library/Application Support/${installedAppDirName()}")
private fun linuxInstalledRoot(): File {
val dirName = installedAppDirName()
val xdg = System.getenv("XDG_DATA_HOME")?.takeIf { it.isNotBlank() }
return if (xdg != null) {
File(xdg, APP_DIR_NAME)
File(xdg, dirName)
} else {
File(requireHome(), ".local/share/$APP_DIR_NAME")
File(requireHome(), ".local/share/$dirName")
}
}