mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 11:05:04 +03:00
Improve the installer, fix context menu on Windows, add CI workflows
This commit is contained in:
@@ -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 }}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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",
|
||||
] }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(®istration_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
|
||||
)
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
+5
-11
@@ -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 parent’s window origin again (double offset
|
||||
// in list–detail 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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+17
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Executable
+54
@@ -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"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user