diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 5062463..6cffcd0 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -47,19 +47,48 @@ jobs: make install make build - - name: Build AppImage + - name: Build AppImage (with embedded update-info) working-directory: examples/todo env: - # The AppImage tooling can't always FUSE-mount inside CI; use - # extract-and-run for linuxdeploy + manual appimagetool. APPIMAGE_EXTRACT_AND_RUN: '1' FRANKENPHP: /usr/local/bin/frankenphp + # AppImageUpdate sidecar will fetch this .zsync URL; it must + # point at the asset we're about to upload to this Release. + APPIMAGE_UPDATE_INFO: | + zsync|${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/Todo-x86_64.AppImage.zsync run: make appimage + - name: Generate zsync metadata + working-directory: examples/todo/build + run: | + sudo apt-get update -qq + sudo apt-get install -y zsync + zsyncmake Todo-x86_64.AppImage -u Todo-x86_64.AppImage + + - name: Generate latest.json appcast + working-directory: examples/todo/build + env: + TAG: ${{ github.ref_name }} + run: | + SIZE=$(stat -c %s Todo-x86_64.AppImage) + SHA=$(sha256sum Todo-x86_64.AppImage | awk '{print $1}') + URL_BASE="${{ github.server_url }}/${{ github.repository }}/releases/download/${TAG}" + jq -n \ + --arg version "$TAG" \ + --arg url "$URL_BASE/Todo-x86_64.AppImage" \ + --arg sha256 "$SHA" \ + --arg zsync "$URL_BASE/Todo-x86_64.AppImage.zsync" \ + --argjson size "$SIZE" \ + --arg released "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{version:$version, released_at:$released, appimage:{url:$url, sha256:$sha256, size:$size, zsync:$zsync}}' \ + > latest.json + cat latest.json + - name: Compute SHA256SUMS working-directory: examples/todo/build run: | - sha256sum Todo-x86_64.AppImage > SHA256SUMS + sha256sum Todo-x86_64.AppImage Todo-x86_64.AppImage.zsync latest.json \ + > SHA256SUMS cat SHA256SUMS - name: Import GPG signing key @@ -117,5 +146,7 @@ jobs: --data-binary "@$f" } upload Todo-x86_64.AppImage + upload Todo-x86_64.AppImage.zsync + upload latest.json upload SHA256SUMS [ -f SHA256SUMS.asc ] && upload SHA256SUMS.asc || true diff --git a/examples/todo/Makefile b/examples/todo/Makefile index 2a99e09..4b70c8b 100644 --- a/examples/todo/Makefile +++ b/examples/todo/Makefile @@ -63,7 +63,8 @@ appimage: build ## Package as a single-file Linux AppImage at build/Todo-x86_64. --caddyfile Caddyfile \ --desktop packaging/todo.desktop \ --icon packaging/todo.png \ - --output build/Todo-x86_64.AppImage + --output build/Todo-x86_64.AppImage \ + $${APPIMAGE_UPDATE_INFO:+--update-info "$$APPIMAGE_UPDATE_INFO"} @echo @echo "AppImage built. Test with: ./build/Todo-x86_64.AppImage" diff --git a/framework/qml/src/BackendConnection.cpp b/framework/qml/src/BackendConnection.cpp index 4d201d5..1d5c229 100644 --- a/framework/qml/src/BackendConnection.cpp +++ b/framework/qml/src/BackendConnection.cpp @@ -432,4 +432,117 @@ QString BackendConnection::randomSecret(int bytes) return QString::fromLatin1(buf.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); } +QString BackendConnection::resolveSidecarUpdater() const +{ + const QByteArray override = qgetenv("BRIDGE_APPIMAGEUPDATE_BIN"); + if (!override.isEmpty()) return QString::fromUtf8(override); + return QCoreApplication::applicationDirPath() + QStringLiteral("/AppImageUpdate.AppImage"); +} + +QString BackendConnection::currentAppImagePath() const +{ + // The AppImage runtime exports APPIMAGE pointing at the on-disk + // AppImage that's currently running. Outside an AppImage this is + // empty — bundled-mode-via-loose-files can't auto-update yet. + return QString::fromUtf8(qgetenv("APPIMAGE")); +} + +void BackendConnection::checkForUpdates() +{ + if (m_mode != Mode::Bundled) { + emit updateCheckFailed(QStringLiteral("update checks are bundled-mode only")); + return; + } + if (m_updateCheck && m_updateCheck->state() != QProcess::NotRunning) { + return; // already in flight + } + const QString sidecar = resolveSidecarUpdater(); + if (!QFileInfo(sidecar).isExecutable()) { + emit updateCheckFailed(QStringLiteral("AppImageUpdate sidecar not found at %1").arg(sidecar)); + return; + } + const QString appImage = currentAppImagePath(); + if (appImage.isEmpty()) { + emit updateCheckFailed(QStringLiteral("APPIMAGE env not set; not running from a packaged AppImage")); + return; + } + + if (m_updateCheck) m_updateCheck->deleteLater(); + m_updateCheck = new QProcess(this); + m_updateCheck->setProgram(sidecar); + // appimageupdatetool exit codes: 0 = no update, 1 = update available, + // anything else = error. The sidecar AppImage forwards to that tool. + m_updateCheck->setArguments({QStringLiteral("--check-for-update"), appImage}); + m_updateCheck->setProcessChannelMode(QProcess::MergedChannels); + connect(m_updateCheck, &QProcess::finished, + this, &BackendConnection::onUpdateCheckFinished); + m_updateCheck->start(); +} + +void BackendConnection::onUpdateCheckFinished(int exitCode, QProcess::ExitStatus status) +{ + Q_UNUSED(status); + if (!m_updateCheck) return; + const QByteArray out = m_updateCheck->readAll(); + m_updateCheck->deleteLater(); + m_updateCheck = nullptr; + + if (exitCode == 0) { + emit noUpdatesAvailable(); + } else if (exitCode == 1) { + emit updatesAvailable(); + } else { + emit updateCheckFailed(QStringLiteral("AppImageUpdate exited %1: %2") + .arg(exitCode) + .arg(QString::fromUtf8(out).trimmed())); + } +} + +void BackendConnection::applyUpdate() +{ + if (m_mode != Mode::Bundled) { + emit updateApplyFailed(QStringLiteral("update apply is bundled-mode only")); + return; + } + if (m_updateApply && m_updateApply->state() != QProcess::NotRunning) { + return; + } + const QString sidecar = resolveSidecarUpdater(); + if (!QFileInfo(sidecar).isExecutable()) { + emit updateApplyFailed(QStringLiteral("AppImageUpdate sidecar not found at %1").arg(sidecar)); + return; + } + const QString appImage = currentAppImagePath(); + if (appImage.isEmpty()) { + emit updateApplyFailed(QStringLiteral("APPIMAGE env not set; not running from a packaged AppImage")); + return; + } + + if (m_updateApply) m_updateApply->deleteLater(); + m_updateApply = new QProcess(this); + m_updateApply->setProgram(sidecar); + m_updateApply->setArguments({QStringLiteral("--remove-old"), appImage}); + m_updateApply->setProcessChannelMode(QProcess::MergedChannels); + connect(m_updateApply, &QProcess::finished, + this, &BackendConnection::onUpdateApplyFinished); + m_updateApply->start(); +} + +void BackendConnection::onUpdateApplyFinished(int exitCode, QProcess::ExitStatus status) +{ + Q_UNUSED(status); + if (!m_updateApply) return; + const QByteArray out = m_updateApply->readAll(); + m_updateApply->deleteLater(); + m_updateApply = nullptr; + + if (exitCode == 0) { + emit updateApplied(); + } else { + emit updateApplyFailed(QStringLiteral("AppImageUpdate exited %1: %2") + .arg(exitCode) + .arg(QString::fromUtf8(out).trimmed())); + } +} + } // namespace PhpQml::Bridge diff --git a/framework/qml/src/BackendConnection.h b/framework/qml/src/BackendConnection.h index 52c0d4c..2f72d22 100644 --- a/framework/qml/src/BackendConnection.h +++ b/framework/qml/src/BackendConnection.h @@ -98,6 +98,8 @@ private slots: void probe(); void onProbeFinished(); void onChildFinished(int exitCode, QProcess::ExitStatus status); + void onUpdateCheckFinished(int exitCode, QProcess::ExitStatus status); + void onUpdateApplyFinished(int exitCode, QProcess::ExitStatus status); private: void initDevMode(); @@ -135,6 +137,12 @@ private: QProcess* m_child = nullptr; int m_supervisorRetries = 0; static constexpr int kMaxSupervisorRetries = 5; + + QProcess* m_updateCheck = nullptr; + QProcess* m_updateApply = nullptr; + + QString resolveSidecarUpdater() const; + QString currentAppImagePath() const; }; } // namespace PhpQml::Bridge diff --git a/packaging/linux/build-appimage.sh b/packaging/linux/build-appimage.sh index 5d4d842..9a439b3 100755 --- a/packaging/linux/build-appimage.sh +++ b/packaging/linux/build-appimage.sh @@ -36,6 +36,7 @@ CADDYFILE="" DESKTOP="" ICON="" OUTPUT="" +UPDATE_INFO="" usage() { cat <&2 @@ -62,6 +63,7 @@ while [ $# -gt 0 ]; do --desktop) DESKTOP="$2"; shift 2 ;; --icon) ICON="$2"; shift 2 ;; --output) OUTPUT="$2"; shift 2 ;; + --update-info) UPDATE_INFO="$2"; shift 2 ;; -h|--help) usage ;; *) echo "unknown arg: $1" >&2; usage ;; esac @@ -116,6 +118,13 @@ if [ ! -x "$APPIMAGETOOL" ]; then https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage chmod +x "$APPIMAGETOOL" fi +APPIMAGEUPDATE="$TOOLS_DIR/AppImageUpdate-x86_64.AppImage" +if [ ! -x "$APPIMAGEUPDATE" ]; then + step "Download AppImageUpdate sidecar" + curl -fsSL -o "$APPIMAGEUPDATE" \ + https://github.com/AppImageCommunity/AppImageUpdate/releases/download/continuous/AppImageUpdate-x86_64.AppImage + chmod +x "$APPIMAGEUPDATE" +fi # Pre-cache the runtime file appimagetool needs; downloading it on every # build was wedging some networks during squashfs. RUNTIME_FILE="$TOOLS_DIR/runtime-x86_64" @@ -135,9 +144,10 @@ trap 'rm -rf "$(dirname "$APPDIR")"' EXIT INT TERM step "Stage AppDir at $APPDIR" mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/share/$APP_NAME" -install -m 0755 "$HOST_BIN" "$APPDIR/usr/bin/$APP_NAME" -install -m 0755 "$FRANKENPHP" "$APPDIR/usr/bin/frankenphp" -install -m 0644 "$CADDYFILE" "$APPDIR/usr/share/$APP_NAME/Caddyfile" +install -m 0755 "$HOST_BIN" "$APPDIR/usr/bin/$APP_NAME" +install -m 0755 "$FRANKENPHP" "$APPDIR/usr/bin/frankenphp" +install -m 0755 "$APPIMAGEUPDATE" "$APPDIR/usr/bin/AppImageUpdate.AppImage" +install -m 0644 "$CADDYFILE" "$APPDIR/usr/share/$APP_NAME/Caddyfile" # Symfony tree: rsync without dev artefacts. Ensure composer ran with # --no-dev before building; this script doesn't re-run composer install. @@ -174,7 +184,14 @@ LD_LIBRARY_PATH="" \ --plugin qt step "appimagetool — squash AppDir into single-file AppImage" -"$APPIMAGETOOL" --runtime-file "$RUNTIME_FILE" "$APPDIR" "$OUTPUT" +APPIMAGETOOL_ARGS=(--runtime-file "$RUNTIME_FILE") +if [ -n "$UPDATE_INFO" ]; then + # Embed AppImageUpdate update-info string (e.g. + # "zsync|https://example.com/Todo-x86_64.AppImage.zsync") into the + # .upd_info ELF section so the bundled sidecar can find new releases. + APPIMAGETOOL_ARGS+=(-u "$UPDATE_INFO") +fi +"$APPIMAGETOOL" "${APPIMAGETOOL_ARGS[@]}" "$APPDIR" "$OUTPUT" echo echo "✓ AppImage at $OUTPUT"