diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 00000000..ac8cafbc --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,70 @@ +name: Release Desktop + +# On a v* tag, builds the desktop installers for Linux (.deb, .rpm, .AppImage, +# .tar.xz), Windows (.msi, .exe) and macOS (.dmg, arm64 + x64) with the tag's +# version and attaches them to the tag's GitHub release, next to the Android +# assets from create-release.yml. + +on: + push: + tags: + - 'v*' + # To rebuild an existing release: Run workflow, "Use workflow from" the tag. + workflow_dispatch: + +permissions: + contents: read + +jobs: + version: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.v.outputs.tag }} + version: ${{ steps.v.outputs.version }} + steps: + # Installers need a plain X.Y.Z: v6.7.0-pre1 packages as 6.7.0. + - id: v + env: + TAG: ${{ github.ref_name }} + run: | + [ "$GITHUB_REF_TYPE" = tag ] || { echo "::error::Run this on a tag, not $GITHUB_REF"; exit 1; } + version=${TAG#v} + version=${version%%-*} + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::Tag $TAG is not vX.Y.Z[-suffix]"; exit 1; } + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + build: + needs: version + uses: ./.github/workflows/desktop.yml + with: + version: ${{ needs.version.outputs.version }} + + release: + needs: [version, build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v8 + with: + pattern: amber-desktop-* + path: dist + merge-multiple: true + + # create-release.yml creates the release for the Android APK at the same + # time; whichever gets there first creates it and the other attaches. + - name: Attach installers to the release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.version.outputs.tag }} + run: | + ls -l dist + prerelease=$([[ "$TAG" == *-pre* ]] && echo --prerelease || true) + for attempt in 1 2 3 4 5; do + gh release view "$TAG" > /dev/null 2>&1 && break + gh release create "$TAG" --verify-tag --title "Release refs/tags/$TAG" --notes "" $prerelease && break + sleep 20 + done + gh release upload "$TAG" dist/* --clobber diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 5bb809eb..889bf5da 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -2,6 +2,7 @@ name: Test/Build Desktop # Lints, tests and packages the Compose for Desktop signer (:desktop) on every # OS it ships for, and uploads the installers so each change can be tried out. +# desktop-release.yml calls it with the release version. on: pull_request: @@ -13,8 +14,16 @@ on: - 'gradle.properties' - '.github/workflows/desktop.yml' push: + # Branches only: tag pushes are built by desktop-release.yml. + branches: ['**'] paths: *desktop-paths workflow_dispatch: + workflow_call: + inputs: + version: + description: 'Package version (X.Y.Z); defaults to the one in desktop/build.gradle.kts' + type: string + default: '' permissions: contents: read @@ -27,6 +36,10 @@ defaults: run: shell: bash +env: + VERSION_ARG: ${{ inputs.version && format('-PdesktopVersion={0}', inputs.version) || '' }} + RELEASE_VERSION: ${{ inputs.version }} + jobs: lint: runs-on: ubuntu-latest @@ -67,9 +80,9 @@ jobs: fail-fast: false matrix: include: - - name: linux + - name: linux-x86_64 os: ubuntu-latest - - name: windows + - name: windows-x64 os: windows-latest - name: macos-arm64 os: macos-latest @@ -98,6 +111,10 @@ jobs: - name: Create keystore.properties run: touch keystore.properties + # Names the installers: the release version, or the commit for CI builds. + - name: Set installer version + run: echo "DIST_VERSION=${RELEASE_VERSION:-dev-${GITHUB_SHA::7}}" >> "$GITHUB_ENV" + - name: Install Linux packaging tools if: runner.os == 'Linux' run: sudo apt-get update -qq && sudo apt-get install -y -qq rpm xvfb @@ -108,36 +125,46 @@ jobs: # .deb/.rpm on Linux, .msi/.exe on Windows (WiX is fetched by the # Compose plugin), .dmg on macOS (unsigned). - name: Package (gradle) - run: ./gradlew :desktop:packageDistributionForCurrentOs --no-daemon + run: ./gradlew :desktop:packageDistributionForCurrentOs $VERSION_ARG --no-daemon - # Start the packaged app on a virtual display and make sure it is still - # alive after a while, i.e. it doesn't crash on startup. HOME points at an - # empty dir so it boots like a first launch. - - name: Smoke test packaged app + # Distro-independent bundles from the standalone app image (packageDeb/Rpm + # build from a temporary one). + - name: Package AppImage and tar.xz if: runner.os == 'Linux' run: | - # packageDeb/Rpm build from a temporary app image; build the - # standalone one to launch. - ./gradlew :desktop:createDistributable --no-daemon - AMBER="$PWD/desktop/build/compose/binaries/main/app/Amber/bin/Amber" - export HOME="$RUNNER_TEMP/amber-home" AMBER_DISABLE_TRAY=1 + ./gradlew :desktop:createDistributable $VERSION_ARG --no-daemon + desktop/packaging/linux.sh "$DIST_VERSION" dist + + # Start the AppImage on a virtual display and make sure it is still alive + # after a while, i.e. it doesn't crash on startup. HOME points at an + # empty dir so it boots like a first launch. + - name: Smoke test AppImage + if: runner.os == 'Linux' + run: | + export HOME="$RUNNER_TEMP/amber-home" AMBER_DISABLE_TRAY=1 APPIMAGE_EXTRACT_AND_RUN=1 mkdir -p "$HOME" - xvfb-run -a "$AMBER" > "$RUNNER_TEMP/amber.log" 2>&1 & + xvfb-run -a dist/Amber-*.AppImage > "$RUNNER_TEMP/amber.log" 2>&1 & sleep 30 cat "$RUNNER_TEMP/amber.log" - pgrep -f 'binaries/main/app/Amber/bin/Amber' > /dev/null || { echo "::error::Amber exited during startup"; exit 1; } - pkill -f 'binaries/main/app/Amber/bin/Amber' || true + pgrep -f 'usr/lib/amber/bin/Amber' > /dev/null || { echo "::error::Amber exited during startup"; exit 1; } + pkill -f 'usr/lib/amber/bin/Amber' || true + + # Name every installer Amber--.: the macOS DMGs + # of both architectures would otherwise collide in the release. + - name: Collect installers + run: | + mkdir -p dist + for f in desktop/build/compose/binaries/main/*/*.{deb,rpm,msi,exe,dmg}; do + [ -e "$f" ] || continue + cp "$f" "dist/Amber-$DIST_VERSION-${{ matrix.name }}.${f##*.}" + done + ls -l dist - name: Upload installers uses: actions/upload-artifact@v7 with: - name: amber-desktop-${{ matrix.name }}-${{ github.sha }} - path: | - desktop/build/compose/binaries/main/deb/*.deb - desktop/build/compose/binaries/main/rpm/*.rpm - desktop/build/compose/binaries/main/msi/*.msi - desktop/build/compose/binaries/main/exe/*.exe - desktop/build/compose/binaries/main/dmg/*.dmg + name: amber-desktop-${{ matrix.name }} + path: dist/* retention-days: 14 if-no-files-found: error diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 6e4991c2..ad09f595 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -71,7 +71,8 @@ compose.desktop { nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Exe, TargetFormat.Deb, TargetFormat.Rpm) packageName = "Amber" - packageVersion = "6.2.3" + // Release builds pass the tag's version (-PdesktopVersion=X.Y.Z). + packageVersion = providers.gradleProperty("desktopVersion").getOrElse("6.2.3") description = "Amber - Nostr event signer" vendor = "greenart7c3" copyright = "© greenart7c3. Distributed under the MIT license." diff --git a/desktop/packaging/linux.sh b/desktop/packaging/linux.sh new file mode 100755 index 00000000..3c75943f --- /dev/null +++ b/desktop/packaging/linux.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Builds the distro-independent Linux bundles from the jpackage app image +# (./gradlew :desktop:createDistributable): +# +# Amber--linux-x86_64.tar.xz unpack anywhere, run Amber/bin/Amber +# Amber--x86_64.AppImage single self-contained executable +# +# desktop/packaging/linux.sh +# +# Needs curl (to fetch appimagetool) and xz. FUSE is not needed: appimagetool +# runs with APPIMAGE_EXTRACT_AND_RUN. +set -euo pipefail + +version=${1:?usage: linux.sh } +out=${2:?usage: linux.sh } +here=$(cd "$(dirname "$0")" && pwd) +desktop=$(cd "$here/.." && pwd) +image="$desktop/build/compose/binaries/main/app/Amber" +work="$desktop/build/linux-bundles" + +[ -x "$image/bin/Amber" ] || { echo "No app image at $image; run :desktop:createDistributable first" >&2; exit 1; } +mkdir -p "$out" +out=$(cd "$out" && pwd) +rm -rf "$work" +mkdir -p "$work" + +tar -C "$(dirname "$image")" -cJf "$out/Amber-$version-linux-x86_64.tar.xz" Amber + +appdir="$work/Amber.AppDir" +mkdir -p "$appdir/usr/lib" +cp -a "$image" "$appdir/usr/lib/amber" +cp "$desktop/src/main/resources/icon.png" "$appdir/amber.png" +ln -s amber.png "$appdir/.DirIcon" +# StartupWMClass matches the X11 class Main.kt sets, so docks show this icon. +cat > "$appdir/amber.desktop" << 'EOF' +[Desktop Entry] +Type=Application +Name=Amber +Comment=Nostr event signer +Exec=Amber %u +Icon=amber +Categories=Network; +Terminal=false +StartupWMClass=Amber +MimeType=x-scheme-handler/nostrconnect; +EOF +cat > "$appdir/AppRun" << 'EOF' +#!/bin/sh +here=$(dirname "$(readlink -f "$0")") +exec "$here/usr/lib/amber/bin/Amber" "$@" +EOF +chmod +x "$appdir/AppRun" + +tool="$work/appimagetool" +curl -fsSL -o "$tool" https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage +chmod +x "$tool" +ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 "$tool" --no-appstream "$appdir" "$out/Amber-$version-x86_64.AppImage" + +ls -l "$out" diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AutoStart.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AutoStart.kt index ec390b20..e93d58b9 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AutoStart.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AutoStart.kt @@ -45,7 +45,12 @@ object AutoStart { /** Set by the jpackage launcher to the installed binary; absent in dev (gradle) runs. */ private fun packagedExecutable(): String? = System.getProperty("jpackage.app-path")?.takeIf { it.isNotBlank() } - private fun currentExecutable(): String? = runCatching { + /** + * Inside an AppImage the binary runs from a per-launch mount + * (/tmp/.mount_*), so use the AppImage file itself; its runtime exports + * the path as APPIMAGE. + */ + private fun currentExecutable(): String? = System.getenv("APPIMAGE")?.takeIf { it.isNotBlank() } ?: runCatching { String(java.nio.file.Files.readAllBytes(java.nio.file.Path.of("/proc/self/cmdline")), Charsets.UTF_8) .split('\u0000') .firstOrNull { it.isNotBlank() } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/UriLaunch.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/UriLaunch.kt index eebe7602..fefe8056 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/UriLaunch.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/UriLaunch.kt @@ -236,7 +236,8 @@ object UriLaunch { /** Desktop-entry Exec quoting: double quotes with backslash escapes. */ internal fun quoteForDesktopEntry(value: String): String = if (value.none { it in " \t\"'\\" }) value else "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" - private fun currentCommandLine(): String? = runCatching { + /** The AppImage file rather than its per-launch mount; see AutoStart.currentExecutable. */ + private fun currentCommandLine(): String? = System.getenv("APPIMAGE")?.takeIf { it.isNotBlank() } ?: runCatching { String(Files.readAllBytes(Path.of("/proc/self/cmdline")), Charsets.UTF_8) .split('\u0000') .firstOrNull { it.isNotBlank() }