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 0000000..d337f15 Binary files /dev/null and b/app/desktop/icons/app_window_icon.ico differ diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopTrayMenu.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopTrayMenu.kt new file mode 100644 index 0000000..a1f3cba --- /dev/null +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopTrayMenu.kt @@ -0,0 +1,227 @@ +package ru.fromchat.desktop + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState +import java.awt.AWTEvent +import java.awt.SystemTray +import java.awt.Toolkit +import java.awt.TrayIcon +import java.awt.event.AWTEventListener +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.image.BufferedImage +import javax.swing.SwingUtilities +import ru.fromchat.ui.components.Text +import ru.fromchat.ui.getColorScheme +import ru.fromchat.ui.isInsideWindow + +@Composable +internal fun DesktopTrayHost( + trayImage: BufferedImage, + tooltip: String, + statusLabel: String, + showLabel: String, + aboutLabel: String, + quitLabel: String, + darkTheme: Boolean, + onShow: () -> Unit, + onAbout: () -> Unit, + onQuit: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + var menuPosition by remember { mutableStateOf(WindowPosition(0.dp, 0.dp)) } + val closeMenu = rememberUpdatedState { menuOpen = false } + val onShowState = rememberUpdatedState(onShow) + + DisposableEffect(trayImage, tooltip) { + if (!SystemTray.isSupported()) { + onDispose {} + } else { + val trayIcon = TrayIcon(trayImage, tooltip) + trayIcon.isImageAutoSize = true + fun openMenu(event: MouseEvent) { + val screen = event.locationOnScreen + SwingUtilities.invokeLater { + menuPosition = WindowPosition( + x = (screen.x - 220).coerceAtLeast(8).dp, + y = (screen.y - 180).coerceAtLeast(8).dp, + ) + menuOpen = true + } + } + val listener = object : MouseAdapter() { + override fun mouseClicked(event: MouseEvent) { + if (MouseEvent.BUTTON1 == event.button && event.clickCount == 1) { + SwingUtilities.invokeLater { onShowState.value() } + } + } + + override fun mousePressed(event: MouseEvent) { + if (event.isPopupTrigger) { + event.consume() + } + } + + override fun mouseReleased(event: MouseEvent) { + if (event.isPopupTrigger) { + openMenu(event) + } + } + } + trayIcon.addMouseListener(listener) + val tray = SystemTray.getSystemTray() + runCatching { tray.add(trayIcon) } + onDispose { + trayIcon.removeMouseListener(listener) + runCatching { tray.remove(trayIcon) } + } + } + } + + if (menuOpen) { + val windowState = rememberWindowState( + position = menuPosition, + width = 240.dp, + height = 220.dp, + ) + Window( + onCloseRequest = { menuOpen = false }, + state = windowState, + title = "", + undecorated = true, + transparent = false, + resizable = false, + alwaysOnTop = true, + focusable = true, + ) { + MaterialTheme(colorScheme = getColorScheme(darkTheme, dynamicColor = false)) { + DisposableEffect(window) { + val toolkit = Toolkit.getDefaultToolkit() + var armed = false + val armTimer = javax.swing.Timer(200) { armed = true }.apply { + isRepeats = false + start() + } + val dismissListener = AWTEventListener { event -> + when (event.id) { + MouseEvent.MOUSE_RELEASED -> armed = true + MouseEvent.MOUSE_PRESSED -> { + if (!armed) return@AWTEventListener + val mouse = event as MouseEvent + if (mouse.button != MouseEvent.BUTTON1) return@AWTEventListener + if (mouse.isInsideWindow(window)) return@AWTEventListener + SwingUtilities.invokeLater { closeMenu.value() } + } + } + } + toolkit.addAWTEventListener(dismissListener, AWTEvent.MOUSE_EVENT_MASK) + onDispose { + armTimer.stop() + toolkit.removeAWTEventListener(dismissListener) + } + } + Column( + modifier = Modifier + .shadow(12.dp, RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(vertical = 6.dp) + .widthIn(min = 220.dp), + ) { + DesktopTrayMenuRow( + text = statusLabel, + enabled = false, + onClick = {}, + ) + HorizontalDivider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f), + ) + DesktopTrayMenuRow(text = showLabel, onClick = { + menuOpen = false + onShow() + }) + DesktopTrayMenuRow(text = aboutLabel, onClick = { + menuOpen = false + onAbout() + }) + HorizontalDivider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f), + ) + DesktopTrayMenuRow(text = quitLabel, onClick = { + menuOpen = false + onQuit() + }) + } + } + } + } +} + +@Composable +private fun DesktopTrayMenuRow( + text: String, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val hovered by interactionSource.collectIsHoveredAsState() + val textColor = when { + !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f) + else -> MaterialTheme.colorScheme.onSurface + } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background( + if (enabled && hovered) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + } else { + androidx.compose.ui.graphics.Color.Transparent + }, + ) + .hoverable(interactionSource, enabled = enabled) + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + onClick = onClick, + ) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = textColor, + ) + } +} 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") } }