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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user