Phase 4a sub-commit 4: AppImageUpdate sidecar + appcast + checkForUpdates()
Wires in the option-(a) sidecar approach: the AppImage carries a
bundled AppImageUpdate AppImage and an embedded update-info string
in the .upd_info ELF section. BackendConnection drives both the
check and the apply via QProcess.
BackendConnection:
- Q_INVOKABLE checkForUpdates()
Bundled mode only. Spawns AppImageUpdate.AppImage with
--check-for-update <APPIMAGE>. Exits 0 → noUpdatesAvailable,
1 → updatesAvailable, anything else → updateCheckFailed.
Dev mode: emits updateCheckFailed("…dev-mode only").
- Q_INVOKABLE applyUpdate()
Bundled mode only. Spawns AppImageUpdate.AppImage with
--remove-old <APPIMAGE>. Replaces the running AppImage in
place; user must restart. Emits updateApplied or
updateApplyFailed.
- Sidecar path resolves to applicationDirPath()/AppImageUpdate.AppImage
by default, overridable via BRIDGE_APPIMAGEUPDATE_BIN.
- APPIMAGE env (set by the AppImage runtime) determines the target
file. Outside an AppImage both methods fail loudly.
build-appimage.sh:
- Auto-downloads AppImageUpdate-x86_64.AppImage into the cached
tools dir and copies it into AppDir/usr/bin/AppImageUpdate.AppImage.
- New --update-info flag, forwarded to appimagetool's -u so the
.upd_info ELF section carries an "zsync|<URL>" string the sidecar
will fetch.
examples/todo Makefile forwards APPIMAGE_UPDATE_INFO env to the
script as --update-info.
release.yml:
- Builds the AppImage with APPIMAGE_UPDATE_INFO set to the canonical
Gitea Releases asset URL for this tag.
- Installs zsync, runs zsyncmake to generate Todo-x86_64.AppImage.zsync.
- Generates a JSON appcast (latest.json) with version / url / sha256 /
size / zsync URL / released_at — useful as an HTTP-fetchable
fallback for clients that prefer a structured manifest.
- SHA256SUMS now covers AppImage + zsync + latest.json.
- Uploads all four assets to the Gitea Release.
AppImage size grows from ~104 MB to ~152 MB with the sidecar bundled.
Embedding verified: objdump shows .upd_info populated with the
expected zsync URL after a local build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,19 +47,48 @@ jobs:
|
|||||||
make install
|
make install
|
||||||
make build
|
make build
|
||||||
|
|
||||||
- name: Build AppImage
|
- name: Build AppImage (with embedded update-info)
|
||||||
working-directory: examples/todo
|
working-directory: examples/todo
|
||||||
env:
|
env:
|
||||||
# The AppImage tooling can't always FUSE-mount inside CI; use
|
|
||||||
# extract-and-run for linuxdeploy + manual appimagetool.
|
|
||||||
APPIMAGE_EXTRACT_AND_RUN: '1'
|
APPIMAGE_EXTRACT_AND_RUN: '1'
|
||||||
FRANKENPHP: /usr/local/bin/frankenphp
|
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
|
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
|
- name: Compute SHA256SUMS
|
||||||
working-directory: examples/todo/build
|
working-directory: examples/todo/build
|
||||||
run: |
|
run: |
|
||||||
sha256sum Todo-x86_64.AppImage > SHA256SUMS
|
sha256sum Todo-x86_64.AppImage Todo-x86_64.AppImage.zsync latest.json \
|
||||||
|
> SHA256SUMS
|
||||||
cat SHA256SUMS
|
cat SHA256SUMS
|
||||||
|
|
||||||
- name: Import GPG signing key
|
- name: Import GPG signing key
|
||||||
@@ -117,5 +146,7 @@ jobs:
|
|||||||
--data-binary "@$f"
|
--data-binary "@$f"
|
||||||
}
|
}
|
||||||
upload Todo-x86_64.AppImage
|
upload Todo-x86_64.AppImage
|
||||||
|
upload Todo-x86_64.AppImage.zsync
|
||||||
|
upload latest.json
|
||||||
upload SHA256SUMS
|
upload SHA256SUMS
|
||||||
[ -f SHA256SUMS.asc ] && upload SHA256SUMS.asc || true
|
[ -f SHA256SUMS.asc ] && upload SHA256SUMS.asc || true
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ appimage: build ## Package as a single-file Linux AppImage at build/Todo-x86_64.
|
|||||||
--caddyfile Caddyfile \
|
--caddyfile Caddyfile \
|
||||||
--desktop packaging/todo.desktop \
|
--desktop packaging/todo.desktop \
|
||||||
--icon packaging/todo.png \
|
--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
|
||||||
@echo "AppImage built. Test with: ./build/Todo-x86_64.AppImage"
|
@echo "AppImage built. Test with: ./build/Todo-x86_64.AppImage"
|
||||||
|
|
||||||
|
|||||||
@@ -432,4 +432,117 @@ QString BackendConnection::randomSecret(int bytes)
|
|||||||
return QString::fromLatin1(buf.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
|
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
|
} // namespace PhpQml::Bridge
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ private slots:
|
|||||||
void probe();
|
void probe();
|
||||||
void onProbeFinished();
|
void onProbeFinished();
|
||||||
void onChildFinished(int exitCode, QProcess::ExitStatus status);
|
void onChildFinished(int exitCode, QProcess::ExitStatus status);
|
||||||
|
void onUpdateCheckFinished(int exitCode, QProcess::ExitStatus status);
|
||||||
|
void onUpdateApplyFinished(int exitCode, QProcess::ExitStatus status);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void initDevMode();
|
void initDevMode();
|
||||||
@@ -135,6 +137,12 @@ private:
|
|||||||
QProcess* m_child = nullptr;
|
QProcess* m_child = nullptr;
|
||||||
int m_supervisorRetries = 0;
|
int m_supervisorRetries = 0;
|
||||||
static constexpr int kMaxSupervisorRetries = 5;
|
static constexpr int kMaxSupervisorRetries = 5;
|
||||||
|
|
||||||
|
QProcess* m_updateCheck = nullptr;
|
||||||
|
QProcess* m_updateApply = nullptr;
|
||||||
|
|
||||||
|
QString resolveSidecarUpdater() const;
|
||||||
|
QString currentAppImagePath() const;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace PhpQml::Bridge
|
} // namespace PhpQml::Bridge
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ CADDYFILE=""
|
|||||||
DESKTOP=""
|
DESKTOP=""
|
||||||
ICON=""
|
ICON=""
|
||||||
OUTPUT=""
|
OUTPUT=""
|
||||||
|
UPDATE_INFO=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<USAGE >&2
|
cat <<USAGE >&2
|
||||||
@@ -62,6 +63,7 @@ while [ $# -gt 0 ]; do
|
|||||||
--desktop) DESKTOP="$2"; shift 2 ;;
|
--desktop) DESKTOP="$2"; shift 2 ;;
|
||||||
--icon) ICON="$2"; shift 2 ;;
|
--icon) ICON="$2"; shift 2 ;;
|
||||||
--output) OUTPUT="$2"; shift 2 ;;
|
--output) OUTPUT="$2"; shift 2 ;;
|
||||||
|
--update-info) UPDATE_INFO="$2"; shift 2 ;;
|
||||||
-h|--help) usage ;;
|
-h|--help) usage ;;
|
||||||
*) echo "unknown arg: $1" >&2; usage ;;
|
*) echo "unknown arg: $1" >&2; usage ;;
|
||||||
esac
|
esac
|
||||||
@@ -116,6 +118,13 @@ if [ ! -x "$APPIMAGETOOL" ]; then
|
|||||||
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||||
chmod +x "$APPIMAGETOOL"
|
chmod +x "$APPIMAGETOOL"
|
||||||
fi
|
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
|
# Pre-cache the runtime file appimagetool needs; downloading it on every
|
||||||
# build was wedging some networks during squashfs.
|
# build was wedging some networks during squashfs.
|
||||||
RUNTIME_FILE="$TOOLS_DIR/runtime-x86_64"
|
RUNTIME_FILE="$TOOLS_DIR/runtime-x86_64"
|
||||||
@@ -135,9 +144,10 @@ trap 'rm -rf "$(dirname "$APPDIR")"' EXIT INT TERM
|
|||||||
|
|
||||||
step "Stage AppDir at $APPDIR"
|
step "Stage AppDir at $APPDIR"
|
||||||
mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/share/$APP_NAME"
|
mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/share/$APP_NAME"
|
||||||
install -m 0755 "$HOST_BIN" "$APPDIR/usr/bin/$APP_NAME"
|
install -m 0755 "$HOST_BIN" "$APPDIR/usr/bin/$APP_NAME"
|
||||||
install -m 0755 "$FRANKENPHP" "$APPDIR/usr/bin/frankenphp"
|
install -m 0755 "$FRANKENPHP" "$APPDIR/usr/bin/frankenphp"
|
||||||
install -m 0644 "$CADDYFILE" "$APPDIR/usr/share/$APP_NAME/Caddyfile"
|
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
|
# Symfony tree: rsync without dev artefacts. Ensure composer ran with
|
||||||
# --no-dev before building; this script doesn't re-run composer install.
|
# --no-dev before building; this script doesn't re-run composer install.
|
||||||
@@ -174,7 +184,14 @@ LD_LIBRARY_PATH="" \
|
|||||||
--plugin qt
|
--plugin qt
|
||||||
|
|
||||||
step "appimagetool — squash AppDir into single-file AppImage"
|
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
|
||||||
echo "✓ AppImage at $OUTPUT"
|
echo "✓ AppImage at $OUTPUT"
|
||||||
|
|||||||
Reference in New Issue
Block a user