From 33f5ac1a1fa41e1e7aa64092fca6843a357bb227 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 2 Sep 2026 23:03:07 +0300 Subject: [PATCH] Improve the installer, fix context menu on Windows, add CI workflows --- .github/workflows/desktop-release.yml | 148 ------ .github/workflows/release.yml | 343 ++++++++++++ README.en.md | 12 +- app/desktop/build.gradle.kts | 489 ++++++++++++++++-- app/desktop/icons/app_window_icon.ico | Bin 0 -> 47490 bytes .../ru/fromchat/desktop/DesktopTrayMenu.kt | 227 ++++++++ .../fromchat/desktop/DesktopWindowChrome.kt | 23 + .../main/kotlin/ru/fromchat/desktop/Main.kt | 138 +++-- .../ru/fromchat/desktop/WindowsAppIdentity.kt | 14 + .../desktop/WindowsDesktopTitleBar.kt | 238 +++++++++ .../ru/fromchat/desktop/WindowsDwmChrome.kt | 47 ++ .../fromchat/desktop/WindowsFrameControls.kt | 87 ++++ .../fromchat/desktop/WindowsNativeChrome.kt | 219 ++++++++ app/desktop/windows-setup/Cargo.toml | 3 +- app/desktop/windows-setup/common/Cargo.toml | 3 +- .../windows-setup/common/src/exe_icon.rs | 58 +++ .../common/src/install_layout.rs | 91 ++++ app/desktop/windows-setup/common/src/lib.rs | 4 + .../windows-setup/common/src/payload.rs | 228 +++++++- .../windows-setup/common/src/progress.rs | 2 + app/desktop/windows-setup/common/src/win.rs | 23 +- app/desktop/windows-setup/helper/Cargo.toml | 6 +- app/desktop/windows-setup/helper/src/main.rs | 83 ++- .../windows-setup/icon-patch/Cargo.toml | 12 + .../windows-setup/icon-patch/src/main.rs | 25 + app/desktop/windows-setup/pack/Cargo.toml | 2 +- app/desktop/windows-setup/pack/src/main.rs | 72 ++- .../portable-launcher/Cargo.toml | 2 +- .../windows-setup/portable-launcher/build.rs | 3 + .../portable-launcher/src/main.rs | 82 ++- app/desktop/windows-setup/setup/Cargo.toml | 16 +- app/desktop/windows-setup/setup/build.rs | 60 ++- app/desktop/windows-setup/setup/src/args.rs | 7 +- app/desktop/windows-setup/setup/src/i18n.rs | 3 + app/desktop/windows-setup/setup/src/main.rs | 7 +- app/desktop/windows-setup/setup/src/ui.rs | 197 ++++--- .../windows-setup/setup/src/win_chrome.rs | 49 +- .../windows-setup/setup/src/win_console.rs | 4 +- app/shared/build.gradle.kts | 27 +- .../ui/chat/ContextMenuPopupHost.android.kt | 3 + .../chat/MessageContextMenuPopup.android.kt | 16 +- .../commonMain/kotlin/ru/fromchat/ui/App.kt | 59 ++- .../kotlin/ru/fromchat/ui/auth/AuthLayout.kt | 2 + .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 3 +- .../fromchat/ui/chat/ContextMenuPopupHost.kt | 4 + .../fromchat/ui/chat/InAppContextMenuPopup.kt | 27 + .../ru/fromchat/ui/chat/MessageContextMenu.kt | 22 +- .../ui/chat/MessageContextMenuPopup.kt | 18 + .../ui/chat/utils/AttachmentDragDrop.kt | 6 +- .../fromchat/ui/main/DesktopDetailNavHosts.kt | 23 +- .../ui/chat/ContextMenuPopupHost.ios.kt | 3 + .../ui/chat/MessageContextMenuPopup.ios.kt | 15 +- .../ru/fromchat/ui/ComposePopupWindow.kt | 51 ++ .../ui/MacOsContextMenuPopupWindow.kt | 194 +++++++ .../ui/chat/ChatTextSelectionMenu.jvm.kt | 62 ++- .../ui/chat/ContextMenuPopupHost.jvm.kt | 4 + .../ui/chat/MessageContextMenuPopup.jvm.kt | 213 +------- .../ui/chat/utils/AttachmentDragDrop.jvm.kt | 48 +- scripts/build-windows-vm.cmd | 37 +- scripts/ci-set-version.sh | 17 + scripts/copy-installers-to-desktop.cmd | 54 +- scripts/ensure-windows-arm64-jdk.cmd | 68 +++ scripts/package-ios-ipa.sh | 54 ++ scripts/run-beta-installer.cmd | 54 ++ scripts/stage-windows-x64-prebuilt.cmd | 19 + .../utils/files/AppDataRoot.jvm.kt | 22 +- 66 files changed, 3384 insertions(+), 768 deletions(-) delete mode 100644 .github/workflows/desktop-release.yml create mode 100644 .github/workflows/release.yml create mode 100644 app/desktop/icons/app_window_icon.ico create mode 100644 app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopTrayMenu.kt create mode 100644 app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowChrome.kt create mode 100644 app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsAppIdentity.kt create mode 100644 app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsDesktopTitleBar.kt create mode 100644 app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsDwmChrome.kt create mode 100644 app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsFrameControls.kt create mode 100644 app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsNativeChrome.kt create mode 100644 app/desktop/windows-setup/common/src/exe_icon.rs create mode 100644 app/desktop/windows-setup/common/src/install_layout.rs create mode 100644 app/desktop/windows-setup/icon-patch/Cargo.toml create mode 100644 app/desktop/windows-setup/icon-patch/src/main.rs create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/ContextMenuPopupHost.android.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ContextMenuPopupHost.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/InAppContextMenuPopup.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/ContextMenuPopupHost.ios.kt create mode 100644 app/shared/src/jvmMain/kotlin/ru/fromchat/ui/ComposePopupWindow.kt create mode 100644 app/shared/src/jvmMain/kotlin/ru/fromchat/ui/MacOsContextMenuPopupWindow.kt create mode 100644 app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/ContextMenuPopupHost.jvm.kt create mode 100755 scripts/ci-set-version.sh create mode 100644 scripts/ensure-windows-arm64-jdk.cmd create mode 100755 scripts/package-ios-ipa.sh create mode 100644 scripts/run-beta-installer.cmd create mode 100644 scripts/stage-windows-x64-prebuilt.cmd diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml deleted file mode 100644 index 4b0b204..0000000 --- a/.github/workflows/desktop-release.yml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6f0880d --- /dev/null +++ b/.github/workflows/release.yml @@ -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 < 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 }} diff --git a/README.en.md b/README.en.md index 1e22a72..8f8f208 100644 --- a/README.en.md +++ b/README.en.md @@ -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--macOS.dmg` | -| `./gradlew :app:desktop:packageReleaseLinux` | Linux | `.deb`, `.rpm`, `.AppImage` (needs `appimagetool`) | -| `./gradlew :app:desktop:packageReleaseWindows` | Windows | `FromChat-Setup-.exe`, `FromChat-Portable-.exe` (Rust toolchain required) | -| `./gradlew :app:desktop:packageReleaseDesktop` | current OS | delegates to the task above | +| Task | Host | Artifacts | +|------------------------------------------------|------------|-------------------------------------------------------------------------------------| +| `./gradlew :app:desktop:packageReleaseMac` | macOS | `FromChat--macOS.dmg` | +| `./gradlew :app:desktop:packageReleaseLinux` | Linux | `.deb`, `.rpm`, `.AppImage` (needs `appimagetool`) | +| `./gradlew :app:desktop:packageReleaseWindows` | Windows | `FromChat-Setup-.exe`, `FromChat-Portable-.exe` (Rust toolchain required) | +| `./gradlew :app:desktop:packageReleaseDesktop` | current OS | delegates to the task above | Outputs land under `app/desktop/build/distributions/release/`. diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts index 59fb0ee..51824a8 100644 --- a/app/desktop/build.gradle.kts +++ b/app/desktop/build.gradle.kts @@ -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) { + 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 = + 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("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("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("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?, + setupOutput: org.gradle.api.provider.Provider, + 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("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("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("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("packageReleaseWindows") { @@ -716,9 +1114,12 @@ tasks.register("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") { diff --git a/app/desktop/icons/app_window_icon.ico b/app/desktop/icons/app_window_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d337f15419f791f1eaccc5abcfb90e70635b4073 GIT binary patch literal 47490 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 z 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 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, + ) + } +} diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowChrome.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowChrome.kt new file mode 100644 index 0000000..80d7ab5 --- /dev/null +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowChrome.kt @@ -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) +} diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt index 66fb2c9..112a40c 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt @@ -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) { } 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) { 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) { "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) { } 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) { 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) { 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) { } 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) { .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") diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsAppIdentity.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsAppIdentity.kt new file mode 100644 index 0000000..7aecf96 --- /dev/null +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsAppIdentity.kt @@ -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)) +} diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsDesktopTitleBar.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsDesktopTitleBar.kt new file mode 100644 index 0000000..d98103a --- /dev/null +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsDesktopTitleBar.kt @@ -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), + ) + } +} diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsDwmChrome.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsDwmChrome.kt new file mode 100644 index 0000000..652d99f --- /dev/null +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsDwmChrome.kt @@ -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) +} diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsFrameControls.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsFrameControls.kt new file mode 100644 index 0000000..d66857f --- /dev/null +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsFrameControls.kt @@ -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 diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsNativeChrome.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsNativeChrome.kt new file mode 100644 index 0000000..3d4c364 --- /dev/null +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsNativeChrome.kt @@ -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) diff --git a/app/desktop/windows-setup/Cargo.toml b/app/desktop/windows-setup/Cargo.toml index cf92d8f..0541f53 100644 --- a/app/desktop/windows-setup/Cargo.toml +++ b/app/desktop/windows-setup/Cargo.toml @@ -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" diff --git a/app/desktop/windows-setup/common/Cargo.toml b/app/desktop/windows-setup/common/Cargo.toml index 0fff744..7ba9c7e 100644 --- a/app/desktop/windows-setup/common/Cargo.toml +++ b/app/desktop/windows-setup/common/Cargo.toml @@ -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 = [ diff --git a/app/desktop/windows-setup/common/src/exe_icon.rs b/app/desktop/windows-setup/common/src/exe_icon.rs new file mode 100644 index 0000000..95d5492 --- /dev/null +++ b/app/desktop/windows-setup/common/src/exe_icon.rs @@ -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(()) +} diff --git a/app/desktop/windows-setup/common/src/install_layout.rs b/app/desktop/windows-setup/common/src/install_layout.rs new file mode 100644 index 0000000..608f690 --- /dev/null +++ b/app/desktop/windows-setup/common/src/install_layout.rs @@ -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 { + 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 { + find_jpackage_app_exe(dest) +} + +pub fn find_jpackage_app_exe(dest: &Path) -> Result { + 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> { + let mut out = Vec::new(); + fn rec(dir: &Path, out: &mut Vec) -> 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) +} diff --git a/app/desktop/windows-setup/common/src/lib.rs b/app/desktop/windows-setup/common/src/lib.rs index 229b848..1df1b71 100644 --- a/app/desktop/windows-setup/common/src/lib.rs +++ b/app/desktop/windows-setup/common/src/lib.rs @@ -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::*; diff --git a/app/desktop/windows-setup/common/src/payload.rs b/app/desktop/windows-setup/common/src/payload.rs index 22f58c0..4aa0e78 100644 --- a/app/desktop/windows-setup/common/src/payload.rs +++ b/app/desktop/windows-setup/common/src/payload.rs @@ -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, pub launcher: Vec, - pub payload_zstd: Vec, + payload_x64: Option>, + payload_arm64: Option>, + legacy_payload: Option>, +} + +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 { + 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 { 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 { 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 Result { + 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>> { + 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> { let mut builder = tar::Builder::new(Vec::new()); builder.append_dir_all(".", dir)?; diff --git a/app/desktop/windows-setup/common/src/progress.rs b/app/desktop/windows-setup/common/src/progress.rs index 3e5a2da..c51e4a7 100644 --- a/app/desktop/windows-setup/common/src/progress.rs +++ b/app/desktop/windows-setup/common/src/progress.rs @@ -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, }, diff --git a/app/desktop/windows-setup/common/src/win.rs b/app/desktop/windows-setup/common/src/win.rs index 24c600a..fe98e20 100644 --- a/app/desktop/windows-setup/common/src/win.rs +++ b/app/desktop/windows-setup/common/src/win.rs @@ -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 { 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 { 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 { +pub fn default_install_dir(all_users: bool, edition: FromChatEdition) -> Result { + 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)) } } diff --git a/app/desktop/windows-setup/helper/Cargo.toml b/app/desktop/windows-setup/helper/Cargo.toml index 5dc6516..bbb0e74 100644 --- a/app/desktop/windows-setup/helper/Cargo.toml +++ b/app/desktop/windows-setup/helper/Cargo.toml @@ -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] diff --git a/app/desktop/windows-setup/helper/src/main.rs b/app/desktop/windows-setup/helper/src/main.rs index a07a566..545adcd 100644 --- a/app/desktop/windows-setup/helper/src/main.rs +++ b/app/desktop/windows-setup/helper/src/main.rs @@ -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 { } fn find_app_exe(dest: &PathBuf) -> Result { - 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> { - let mut out = Vec::new(); - fn rec(dir: &PathBuf, out: &mut Vec) -> 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) } diff --git a/app/desktop/windows-setup/icon-patch/Cargo.toml b/app/desktop/windows-setup/icon-patch/Cargo.toml new file mode 100644 index 0000000..78d51a3 --- /dev/null +++ b/app/desktop/windows-setup/icon-patch/Cargo.toml @@ -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" } diff --git a/app/desktop/windows-setup/icon-patch/src/main.rs b/app/desktop/windows-setup/icon-patch/src/main.rs new file mode 100644 index 0000000..3458f70 --- /dev/null +++ b/app/desktop/windows-setup/icon-patch/src/main.rs @@ -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 ")?, + ); + if !exe.is_file() { + bail!("not a file: {}", exe.display()); + } + patch_jpackage_exe_icon(&exe)?; + println!("Patched icon: {}", exe.display()); + Ok(()) +} diff --git a/app/desktop/windows-setup/pack/Cargo.toml b/app/desktop/windows-setup/pack/Cargo.toml index a3373ff..81c4e33 100644 --- a/app/desktop/windows-setup/pack/Cargo.toml +++ b/app/desktop/windows-setup/pack/Cargo.toml @@ -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 diff --git a/app/desktop/windows-setup/pack/src/main.rs b/app/desktop/windows-setup/pack/src/main.rs index 7bcb2bd..a72557e 100644 --- a/app/desktop/windows-setup/pack/src/main.rs +++ b/app/desktop/windows-setup/pack/src/main.rs @@ -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, + #[arg(long)] + app_image_x64: Option, + #[arg(long)] + app_image_arm64: Option, #[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(()) diff --git a/app/desktop/windows-setup/portable-launcher/Cargo.toml b/app/desktop/windows-setup/portable-launcher/Cargo.toml index a86b011..15a05fd 100644 --- a/app/desktop/windows-setup/portable-launcher/Cargo.toml +++ b/app/desktop/windows-setup/portable-launcher/Cargo.toml @@ -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" diff --git a/app/desktop/windows-setup/portable-launcher/build.rs b/app/desktop/windows-setup/portable-launcher/build.rs index bd9fd6d..ce23dd1 100644 --- a/app/desktop/windows-setup/portable-launcher/build.rs +++ b/app/desktop/windows-setup/portable-launcher/build.rs @@ -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}"); } diff --git a/app/desktop/windows-setup/portable-launcher/src/main.rs b/app/desktop/windows-setup/portable-launcher/src/main.rs index ec53b99..ca2e750 100644 --- a/app/desktop/windows-setup/portable-launcher/src/main.rs +++ b/app/desktop/windows-setup/portable-launcher/src/main.rs @@ -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 { - 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> { diff --git a/app/desktop/windows-setup/setup/Cargo.toml b/app/desktop/windows-setup/setup/Cargo.toml index 863eb77..6900990 100644 --- a/app/desktop/windows-setup/setup/Cargo.toml +++ b/app/desktop/windows-setup/setup/Cargo.toml @@ -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", ] } diff --git a/app/desktop/windows-setup/setup/build.rs b/app/desktop/windows-setup/setup/build.rs index 5dbf7d4..6eaddf0 100644 --- a/app/desktop/windows-setup/setup/build.rs +++ b/app/desktop/windows-setup/setup/build.rs @@ -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 = 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 = 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); diff --git a/app/desktop/windows-setup/setup/src/args.rs b/app/desktop/windows-setup/setup/src/args.rs index 55076a3..73a3f4c 100644 --- a/app/desktop/windows-setup/setup/src/args.rs +++ b/app/desktop/windows-setup/setup/src/args.rs @@ -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 = 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 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"); diff --git a/app/desktop/windows-setup/setup/src/ui.rs b/app/desktop/windows-setup/setup/src/ui.rs index b2d0e31..6658b0a 100644 --- a/app/desktop/windows-setup/setup/src/ui.rs +++ b/app/desktop/windows-setup/setup/src/ui.rs @@ -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>, time: f32, @@ -62,10 +64,13 @@ pub struct SetupApp { installed: Option, 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, 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, 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 { 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 { 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 &'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, ) -> 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 { - 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> { @@ -1743,17 +1771,18 @@ fn walkdir_files(root: &PathBuf) -> anyhow::Result> { 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()) } } diff --git a/app/desktop/windows-setup/setup/src/win_chrome.rs b/app/desktop/windows-setup/setup/src/win_chrome.rs index 9468cf7..8c89626 100644 --- a/app/desktop/windows-setup/setup/src/win_chrome.rs +++ b/app/desktop/windows-setup/setup/src/win_chrome.rs @@ -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 = 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) {} diff --git a/app/desktop/windows-setup/setup/src/win_console.rs b/app/desktop/windows-setup/setup/src/win_console.rs index 35fd000..1116719 100644 --- a/app/desktop/windows-setup/setup/src/win_console.rs +++ b/app/desktop/windows-setup/setup/src/win_console.rs @@ -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 ) }); diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 274aa1c..579e86d 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -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" diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/ContextMenuPopupHost.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/ContextMenuPopupHost.android.kt new file mode 100644 index 0000000..9dd7a61 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/ContextMenuPopupHost.android.kt @@ -0,0 +1,3 @@ +package ru.fromchat.ui.chat + +internal actual fun usesMacOsOffscreenContextMenu(): Boolean = false diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.android.kt index 6e222a5..c4ee7eb 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.android.kt @@ -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, ) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index e601e46..baace52 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -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(null) } + var settingsProfileUserId by remember { mutableStateOf(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) } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthLayout.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthLayout.kt index f5491f0..a79d12f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthLayout.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthLayout.kt @@ -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( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 5fa0a4c..a61d618 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -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 = diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ContextMenuPopupHost.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ContextMenuPopupHost.kt new file mode 100644 index 0000000..8e549bf --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ContextMenuPopupHost.kt @@ -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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/InAppContextMenuPopup.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/InAppContextMenuPopup.kt new file mode 100644 index 0000000..135d629 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/InAppContextMenuPopup.kt @@ -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, + ) +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt index 1cb9587..5242ff1 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt @@ -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( 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) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.kt new file mode 100644 index 0000000..15b20d7 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.kt @@ -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, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.kt index 2a1055f..9dc437b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.kt @@ -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, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt index 438fe8e..8bcc7fc 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/DesktopDetailNavHosts.kt @@ -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), + ) + } } } diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/ContextMenuPopupHost.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/ContextMenuPopupHost.ios.kt new file mode 100644 index 0000000..9dd7a61 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/ContextMenuPopupHost.ios.kt @@ -0,0 +1,3 @@ +package ru.fromchat.ui.chat + +internal actual fun usesMacOsOffscreenContextMenu(): Boolean = false diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.ios.kt index a2ccc17..c4ee7eb 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.ios.kt @@ -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, ) } diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/ComposePopupWindow.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/ComposePopupWindow.kt new file mode 100644 index 0000000..ced3833 --- /dev/null +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/ComposePopupWindow.kt @@ -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) +} diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/MacOsContextMenuPopupWindow.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/MacOsContextMenuPopupWindow.kt new file mode 100644 index 0000000..f514552 --- /dev/null +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/MacOsContextMenuPopupWindow.kt @@ -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) +} diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/ChatTextSelectionMenu.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/ChatTextSelectionMenu.jvm.kt index 74214d3..59d9bfb 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/ChatTextSelectionMenu.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/ChatTextSelectionMenu.jvm.kt @@ -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, + ) + } } } diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/ContextMenuPopupHost.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/ContextMenuPopupHost.jvm.kt new file mode 100644 index 0000000..29781ad --- /dev/null +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/ContextMenuPopupHost.jvm.kt @@ -0,0 +1,4 @@ +package ru.fromchat.ui.chat + +internal actual fun usesMacOsOffscreenContextMenu(): Boolean = + System.getProperty("os.name").orEmpty().lowercase().contains("mac") diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.jvm.kt index 962355e..55dc098 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/MessageContextMenuPopup.jvm.kt @@ -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()) - } } diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.jvm.kt index 7009704..7e4f130 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDragDrop.jvm.kt @@ -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 { - // 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() diff --git a/scripts/build-windows-vm.cmd b/scripts/build-windows-vm.cmd index 28ca978..85b1a61 100644 --- a/scripts/build-windows-vm.cmd +++ b/scripts/build-windows-vm.cmd @@ -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 ^ 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 diff --git a/scripts/ci-set-version.sh b/scripts/ci-set-version.sh new file mode 100755 index 0000000..6d80858 --- /dev/null +++ b/scripts/ci-set-version.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${1:?usage: ci-set-version.sh }" +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" diff --git a/scripts/copy-installers-to-desktop.cmd b/scripts/copy-installers-to-desktop.cmd index 0f86009..3c05011 100644 --- a/scripts/copy-installers-to-desktop.cmd +++ b/scripts/copy-installers-to-desktop.cmd @@ -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 diff --git a/scripts/ensure-windows-arm64-jdk.cmd b/scripts/ensure-windows-arm64-jdk.cmd new file mode 100644 index 0000000..f462100 --- /dev/null +++ b/scripts/ensure-windows-arm64-jdk.cmd @@ -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 diff --git a/scripts/package-ios-ipa.sh b/scripts/package-ios-ipa.sh new file mode 100755 index 0000000..9e85daa --- /dev/null +++ b/scripts/package-ios-ipa.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${1:?usage: package-ios-ipa.sh }" +OUT_DIR="${2:?usage: package-ios-ipa.sh }" +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" diff --git a/scripts/run-beta-installer.cmd b/scripts/run-beta-installer.cmd new file mode 100644 index 0000000..48c01b1 --- /dev/null +++ b/scripts/run-beta-installer.cmd @@ -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 diff --git a/scripts/stage-windows-x64-prebuilt.cmd b/scripts/stage-windows-x64-prebuilt.cmd new file mode 100644 index 0000000..dbe40dc --- /dev/null +++ b/scripts/stage-windows-x64-prebuilt.cmd @@ -0,0 +1,19 @@ +@echo off +setlocal EnableExtensions +if "%~1"=="" ( + echo Usage: stage-windows-x64-prebuilt.cmd ^ + 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 diff --git a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/AppDataRoot.jvm.kt b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/AppDataRoot.jvm.kt index 2dc26bf..f786e78 100644 --- a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/AppDataRoot.jvm.kt +++ b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/AppDataRoot.jvm.kt @@ -6,7 +6,7 @@ import java.io.File * Resolves the FromChat desktop data/cache root on the JVM. * * - Portable (`-Dfromchat.portable=true`): `/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") } }