Symfony defaults Kernel::getCacheDir/getLogDir to <project>/var/cache
and <project>/var/log. In bundled mode those resolve inside the
AppImage's FUSE mount (/tmp/.mount_<hash>/usr/share/<app>/symfony/) —
read-only. Migrations fail at startup with:
Unable to create the "cache" directory
(/tmp/.mount_<hash>/usr/share/<app>/symfony/var/cache/prod).
…and frankenphp's worker can't warm a cache either, so even after the
binary spawns, the app is in a half-working state (which probably also
explains the persistent Reconnecting banner the user reported — once
migrations fail the supervisor sets Offline; even a successful
re-probe of /healthz wouldn't recover from a half-warm state).
Two-part fix, framework-side seam + app-side override:
1. BackendConnection.cpp (runMigrations + spawnChild): mkdir
<m_dataDir>/var/{cache,log} and pass them as APP_CACHE_DIR /
APP_LOG_DIR env vars. <m_dataDir> resolves to
~/.local/share/<app> via QStandardPaths::AppDataLocation, so
it's user-writable.
2. App Kernel.php (skeleton + todo): override getCacheDir /
getLogDir to honour the env vars. Falls back to parent
behaviour when unset (dev mode keeps writing to var/cache like
normal).
Database file already lives at <m_dataDir>/var/data.sqlite, so the DB
side was fine. Caddy autosaves to ~/.config/caddy and TLS storage to
~/.local/share/caddy — both user-writable. Mercure ran in-memory
mode in earlier logs so no extra storage redirect needed there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
606 lines
20 KiB
C++
606 lines
20 KiB
C++
#include "BackendConnection.h"
|
|
|
|
#include <QCoreApplication>
|
|
#include <QDebug>
|
|
#include <QDir>
|
|
#include <QFileInfo>
|
|
#include <QLoggingCategory>
|
|
#include <QNetworkAccessManager>
|
|
#include <QNetworkReply>
|
|
#include <QNetworkRequest>
|
|
#include <QProcessEnvironment>
|
|
#include <QQmlEngine>
|
|
#include <QRandomGenerator>
|
|
#include <QStandardPaths>
|
|
#include <QTimer>
|
|
#include <QUrl>
|
|
|
|
#include <csignal>
|
|
#include <sys/prctl.h>
|
|
|
|
namespace PhpQml::Bridge {
|
|
|
|
Q_LOGGING_CATEGORY(lcBundled, "phpqml.bridge.bundled")
|
|
|
|
namespace {
|
|
constexpr int kInitialProbeMs = 0;
|
|
constexpr int kProbeIntervalMs = 5000;
|
|
constexpr int kProbeTimeoutMs = 2000;
|
|
constexpr int kMigrateTimeoutMs = 60000;
|
|
constexpr int kBootProbeMaxMs = 10000;
|
|
} // namespace
|
|
|
|
BackendConnection::BackendConnection(QObject* parent)
|
|
: QObject(parent)
|
|
, m_appName(QCoreApplication::applicationName().isEmpty()
|
|
? QStringLiteral("php-qml-app")
|
|
: QCoreApplication::applicationName())
|
|
, m_nam(new QNetworkAccessManager(this))
|
|
, m_retryTimer(new QTimer(this))
|
|
{
|
|
m_retryTimer->setSingleShot(false);
|
|
m_retryTimer->setInterval(kProbeIntervalMs);
|
|
connect(m_retryTimer, &QTimer::timeout, this, &BackendConnection::probe);
|
|
|
|
const QString explicitUrl = QString::fromUtf8(qgetenv("BRIDGE_URL"));
|
|
if (!explicitUrl.isEmpty()) {
|
|
m_mode = Mode::Dev;
|
|
m_url = explicitUrl;
|
|
m_token = QString::fromUtf8(qgetenv("BRIDGE_TOKEN"));
|
|
QTimer::singleShot(0, this, &BackendConnection::initDevMode);
|
|
} else {
|
|
m_mode = Mode::Bundled;
|
|
// Bundled init runs after the QApplication event loop is up so
|
|
// QStandardPaths and QProcess work correctly.
|
|
QTimer::singleShot(0, this, &BackendConnection::initBundledMode);
|
|
}
|
|
}
|
|
|
|
BackendConnection::~BackendConnection()
|
|
{
|
|
teardownChild();
|
|
}
|
|
|
|
BackendConnection* BackendConnection::create(QQmlEngine* engine, QJSEngine*)
|
|
{
|
|
return new BackendConnection(engine);
|
|
}
|
|
|
|
void BackendConnection::initDevMode()
|
|
{
|
|
if (m_url.isEmpty()) {
|
|
m_url = QStringLiteral("http://127.0.0.1:8765");
|
|
}
|
|
emit urlChanged();
|
|
QTimer::singleShot(kInitialProbeMs, this, &BackendConnection::probe);
|
|
m_retryTimer->start();
|
|
}
|
|
|
|
void BackendConnection::initBundledMode()
|
|
{
|
|
const QString fpBin = resolveFrankenphpBin();
|
|
const QString symfony = resolveSymfonyDir();
|
|
const QString caddyCfg = resolveCaddyfilePath();
|
|
|
|
if (!QFileInfo(fpBin).isExecutable()) {
|
|
const QString msg = QStringLiteral("bundled: frankenphp not found at %1 (override with BRIDGE_FRANKENPHP_BIN)").arg(fpBin);
|
|
qCWarning(lcBundled) << msg;
|
|
setError(msg);
|
|
setState(ConnectionState::Offline);
|
|
return;
|
|
}
|
|
if (symfony.isEmpty() || !QFileInfo(symfony + "/public/index.php").exists()) {
|
|
const QString msg = QStringLiteral("bundled: symfony app not found near %1 (override with BRIDGE_SYMFONY_DIR)").arg(QCoreApplication::applicationDirPath());
|
|
qCWarning(lcBundled) << msg;
|
|
setError(msg);
|
|
setState(ConnectionState::Offline);
|
|
return;
|
|
}
|
|
if (caddyCfg.isEmpty() || !QFileInfo(caddyCfg).exists()) {
|
|
const QString msg = QStringLiteral("bundled: Caddyfile not found near %1 (override with BRIDGE_CADDYFILE)").arg(QCoreApplication::applicationDirPath());
|
|
qCWarning(lcBundled) << msg;
|
|
setError(msg);
|
|
setState(ConnectionState::Offline);
|
|
return;
|
|
}
|
|
qCInfo(lcBundled) << "frankenphp:" << fpBin;
|
|
qCInfo(lcBundled) << "symfony:" << symfony;
|
|
qCInfo(lcBundled) << "caddyfile:" << caddyCfg;
|
|
|
|
m_dataDir = userDataDir();
|
|
QDir().mkpath(m_dataDir + "/var/log");
|
|
QDir().mkpath(m_dataDir + "/var/cache");
|
|
|
|
setToken(randomSecret(32));
|
|
m_jwtSecret = randomSecret(48); // ≥256 bits for lcobucci/jwt
|
|
setUrl(QStringLiteral("http://127.0.0.1:%1").arg(m_port));
|
|
|
|
if (!runMigrations()) {
|
|
return; // setError already invoked
|
|
}
|
|
|
|
QString spawnErr;
|
|
if (!spawnChild(&spawnErr)) {
|
|
setError(spawnErr);
|
|
setState(ConnectionState::Offline);
|
|
return;
|
|
}
|
|
|
|
setState(ConnectionState::Connecting);
|
|
QTimer::singleShot(kInitialProbeMs, this, &BackendConnection::probe);
|
|
m_retryTimer->start();
|
|
}
|
|
|
|
QString BackendConnection::resolveFrankenphpBin() const
|
|
{
|
|
const QByteArray override = qgetenv("BRIDGE_FRANKENPHP_BIN");
|
|
if (!override.isEmpty()) return QString::fromUtf8(override);
|
|
// build-appimage.sh installs frankenphp as a sibling of the host binary at usr/bin/frankenphp.
|
|
return QCoreApplication::applicationDirPath() + QStringLiteral("/frankenphp");
|
|
}
|
|
|
|
QString BackendConnection::resolveSymfonyDir() const
|
|
{
|
|
const QByteArray override = qgetenv("BRIDGE_SYMFONY_DIR");
|
|
if (!override.isEmpty()) return QString::fromUtf8(override);
|
|
|
|
const QString here = QCoreApplication::applicationDirPath();
|
|
const QStringList candidates = {
|
|
here + QStringLiteral("/symfony"),
|
|
here + QStringLiteral("/../symfony"),
|
|
here + QStringLiteral("/../share/") + m_appName + QStringLiteral("/symfony"),
|
|
here + QStringLiteral("/../usr/share/") + m_appName + QStringLiteral("/symfony"),
|
|
};
|
|
for (const QString& c : candidates) {
|
|
if (QFileInfo(c + "/public/index.php").exists()) {
|
|
return QDir(c).absolutePath();
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
QString BackendConnection::resolveCaddyfilePath() const
|
|
{
|
|
const QByteArray override = qgetenv("BRIDGE_CADDYFILE");
|
|
if (!override.isEmpty()) return QString::fromUtf8(override);
|
|
|
|
const QString here = QCoreApplication::applicationDirPath();
|
|
const QStringList candidates = {
|
|
here + QStringLiteral("/Caddyfile"),
|
|
here + QStringLiteral("/../Caddyfile"),
|
|
here + QStringLiteral("/../share/") + m_appName + QStringLiteral("/Caddyfile"),
|
|
here + QStringLiteral("/../usr/share/") + m_appName + QStringLiteral("/Caddyfile"),
|
|
};
|
|
for (const QString& c : candidates) {
|
|
if (QFileInfo(c).exists()) {
|
|
return QFileInfo(c).absoluteFilePath();
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
QString BackendConnection::userDataDir() const
|
|
{
|
|
QString p = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
|
if (p.isEmpty()) {
|
|
p = QDir::homePath() + QStringLiteral("/.local/share/") + m_appName;
|
|
}
|
|
return p;
|
|
}
|
|
|
|
QString BackendConnection::databaseUrl() const
|
|
{
|
|
return QStringLiteral("sqlite:///%1/var/data.sqlite").arg(m_dataDir);
|
|
}
|
|
|
|
bool BackendConnection::runMigrations()
|
|
{
|
|
QProcess proc;
|
|
proc.setProgram(resolveFrankenphpBin());
|
|
proc.setArguments({
|
|
QStringLiteral("php-cli"),
|
|
resolveSymfonyDir() + QStringLiteral("/bin/console"),
|
|
QStringLiteral("doctrine:migrations:migrate"),
|
|
QStringLiteral("-n"),
|
|
});
|
|
|
|
// AppImage mount is read-only; redirect Symfony's writable dirs into the
|
|
// user data dir (Kernel::getCacheDir/getLogDir read these env vars).
|
|
const QString cacheDir = m_dataDir + QStringLiteral("/var/cache");
|
|
const QString logDir = m_dataDir + QStringLiteral("/var/log");
|
|
QDir().mkpath(cacheDir);
|
|
QDir().mkpath(logDir);
|
|
|
|
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
|
|
env.insert(QStringLiteral("APP_ENV"), QStringLiteral("prod"));
|
|
env.insert(QStringLiteral("APP_DEBUG"), QStringLiteral("0"));
|
|
env.insert(QStringLiteral("APP_SECRET"), QStringLiteral("bundled-mode-migrations-do-not-need-this"));
|
|
env.insert(QStringLiteral("DATABASE_URL"), databaseUrl());
|
|
env.insert(QStringLiteral("APP_CACHE_DIR"), cacheDir);
|
|
env.insert(QStringLiteral("APP_LOG_DIR"), logDir);
|
|
proc.setProcessEnvironment(env);
|
|
proc.setWorkingDirectory(resolveSymfonyDir());
|
|
proc.setProcessChannelMode(QProcess::MergedChannels);
|
|
|
|
qCInfo(lcBundled) << "running migrations against" << databaseUrl();
|
|
proc.start();
|
|
if (!proc.waitForFinished(kMigrateTimeoutMs)) {
|
|
proc.kill();
|
|
const QString msg = QStringLiteral("bundled: migrations timed out");
|
|
qCWarning(lcBundled) << msg;
|
|
setError(msg);
|
|
setState(ConnectionState::Offline);
|
|
return false;
|
|
}
|
|
if (proc.exitStatus() != QProcess::NormalExit || proc.exitCode() != 0) {
|
|
const QString msg = QStringLiteral("bundled: migrations failed (exit %1)\n%2")
|
|
.arg(proc.exitCode())
|
|
.arg(QString::fromUtf8(proc.readAll()));
|
|
qCWarning(lcBundled).noquote() << msg;
|
|
setError(msg);
|
|
setState(ConnectionState::Offline);
|
|
return false;
|
|
}
|
|
qCInfo(lcBundled) << "migrations OK";
|
|
return true;
|
|
}
|
|
|
|
bool BackendConnection::spawnChild(QString* errorOut)
|
|
{
|
|
teardownChild();
|
|
|
|
m_child = new QProcess(this);
|
|
m_child->setProgram(resolveFrankenphpBin());
|
|
m_child->setArguments({
|
|
QStringLiteral("run"),
|
|
QStringLiteral("--config"),
|
|
resolveCaddyfilePath(),
|
|
});
|
|
m_child->setWorkingDirectory(resolveSymfonyDir());
|
|
|
|
// AppImage mount is read-only; redirect Symfony's writable dirs into the
|
|
// user data dir (Kernel::getCacheDir/getLogDir read these env vars).
|
|
const QString cacheDir = m_dataDir + QStringLiteral("/var/cache");
|
|
const QString logDir = m_dataDir + QStringLiteral("/var/log");
|
|
QDir().mkpath(cacheDir);
|
|
QDir().mkpath(logDir);
|
|
|
|
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
|
|
env.insert(QStringLiteral("APP_ENV"), QStringLiteral("prod"));
|
|
env.insert(QStringLiteral("APP_DEBUG"), QStringLiteral("0"));
|
|
env.insert(QStringLiteral("APP_SECRET"), randomSecret(16));
|
|
env.insert(QStringLiteral("BRIDGE_TOKEN"), m_token);
|
|
env.insert(QStringLiteral("DATABASE_URL"), databaseUrl());
|
|
env.insert(QStringLiteral("APP_CACHE_DIR"), cacheDir);
|
|
env.insert(QStringLiteral("APP_LOG_DIR"), logDir);
|
|
env.insert(QStringLiteral("PORT"), QString::number(m_port));
|
|
env.insert(QStringLiteral("MERCURE_URL"), m_url + QStringLiteral("/.well-known/mercure"));
|
|
env.insert(QStringLiteral("MERCURE_PUBLIC_URL"), m_url + QStringLiteral("/.well-known/mercure"));
|
|
env.insert(QStringLiteral("MERCURE_JWT_SECRET"), m_jwtSecret);
|
|
env.insert(QStringLiteral("MERCURE_PUBLISHER_JWT_KEY"), m_jwtSecret);
|
|
env.insert(QStringLiteral("MERCURE_SUBSCRIBER_JWT_KEY"), m_jwtSecret);
|
|
m_child->setProcessEnvironment(env);
|
|
// Capture both streams into a single chronological buffer so the
|
|
// DevConsole sees stderr (FrankenPHP / Symfony logs) interleaved with
|
|
// stdout the same way a TTY would. The console keeps still emitting
|
|
// each line via qCInfo(lcBundled) so terminal users aren't blinded.
|
|
m_child->setProcessChannelMode(QProcess::MergedChannels);
|
|
|
|
// Linux: kernel kills the child if the parent dies for any reason.
|
|
m_child->setChildProcessModifier([] {
|
|
prctl(PR_SET_PDEATHSIG, SIGTERM);
|
|
});
|
|
|
|
connect(m_child, &QProcess::finished,
|
|
this, &BackendConnection::onChildFinished);
|
|
connect(m_child, &QProcess::readyReadStandardOutput,
|
|
this, &BackendConnection::onChildOutputReady);
|
|
|
|
qCInfo(lcBundled) << "spawning frankenphp on port" << m_port;
|
|
m_child->start();
|
|
if (!m_child->waitForStarted(5000)) {
|
|
const QString err = QStringLiteral("bundled: failed to start frankenphp: ") + m_child->errorString();
|
|
qCWarning(lcBundled) << err;
|
|
if (errorOut) *errorOut = err;
|
|
teardownChild();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void BackendConnection::teardownChild()
|
|
{
|
|
if (!m_child) return;
|
|
if (m_child->state() != QProcess::NotRunning) {
|
|
m_child->terminate();
|
|
if (!m_child->waitForFinished(2000)) {
|
|
m_child->kill();
|
|
m_child->waitForFinished(1000);
|
|
}
|
|
}
|
|
disconnect(m_child, nullptr, this, nullptr);
|
|
m_child->deleteLater();
|
|
m_child = nullptr;
|
|
m_childLogBuffer.clear();
|
|
}
|
|
|
|
void BackendConnection::onChildOutputReady()
|
|
{
|
|
if (!m_child) return;
|
|
m_childLogBuffer += QString::fromUtf8(m_child->readAllStandardOutput());
|
|
|
|
int nl;
|
|
while ((nl = m_childLogBuffer.indexOf(QLatin1Char('\n'))) >= 0) {
|
|
QString line = m_childLogBuffer.left(nl);
|
|
m_childLogBuffer.remove(0, nl + 1);
|
|
if (line.endsWith(QLatin1Char('\r'))) {
|
|
line.chop(1);
|
|
}
|
|
qCInfo(lcBundled).noquote() << line;
|
|
m_childLog.enqueue(line);
|
|
while (m_childLog.size() > kChildLogMax) {
|
|
m_childLog.dequeue();
|
|
}
|
|
emit childLogLine(line);
|
|
}
|
|
}
|
|
|
|
QStringList BackendConnection::childLogTail() const
|
|
{
|
|
QStringList out;
|
|
out.reserve(m_childLog.size());
|
|
for (const QString& l : m_childLog) {
|
|
out.append(l);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
void BackendConnection::onChildFinished(int exitCode, QProcess::ExitStatus status)
|
|
{
|
|
Q_UNUSED(status);
|
|
if (m_mode != Mode::Bundled) return;
|
|
|
|
setError(QStringLiteral("bundled: frankenphp exited (code=%1); supervisor restart").arg(exitCode));
|
|
|
|
if (m_supervisorRetries >= kMaxSupervisorRetries) {
|
|
setState(ConnectionState::Offline);
|
|
return;
|
|
}
|
|
++m_supervisorRetries;
|
|
|
|
// New session secret on every restart.
|
|
setToken(randomSecret(32));
|
|
emit tokenRotated(m_token);
|
|
|
|
QString err;
|
|
if (!spawnChild(&err)) {
|
|
setError(err);
|
|
setState(ConnectionState::Offline);
|
|
return;
|
|
}
|
|
setState(ConnectionState::Reconnecting);
|
|
}
|
|
|
|
void BackendConnection::restart()
|
|
{
|
|
if (m_mode == Mode::Bundled) {
|
|
m_supervisorRetries = 0;
|
|
QString err;
|
|
if (!spawnChild(&err)) {
|
|
setError(err);
|
|
setState(ConnectionState::Offline);
|
|
return;
|
|
}
|
|
setState(ConnectionState::Connecting);
|
|
}
|
|
probe();
|
|
}
|
|
|
|
void BackendConnection::probe()
|
|
{
|
|
if (m_pendingReply) return;
|
|
|
|
QNetworkRequest req;
|
|
req.setUrl(QUrl(m_url + QStringLiteral("/healthz")));
|
|
req.setTransferTimeout(kProbeTimeoutMs);
|
|
if (!m_token.isEmpty()) {
|
|
req.setRawHeader("Authorization", "Bearer " + m_token.toUtf8());
|
|
}
|
|
|
|
m_pendingReply = m_nam->get(req);
|
|
connect(m_pendingReply, &QNetworkReply::finished,
|
|
this, &BackendConnection::onProbeFinished);
|
|
}
|
|
|
|
void BackendConnection::onProbeFinished()
|
|
{
|
|
QNetworkReply* reply = m_pendingReply;
|
|
m_pendingReply = nullptr;
|
|
if (!reply) return;
|
|
reply->deleteLater();
|
|
|
|
bool ok = false;
|
|
if (reply->error() == QNetworkReply::NoError) {
|
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
if (status == 200) {
|
|
ok = true;
|
|
} else {
|
|
setError(QStringLiteral("/healthz returned HTTP %1").arg(status));
|
|
}
|
|
} else {
|
|
setError(reply->errorString());
|
|
}
|
|
|
|
if (ok) {
|
|
setError(QString());
|
|
m_firstFailureSinceOnline.invalidate();
|
|
m_supervisorRetries = 0;
|
|
setState(ConnectionState::Online);
|
|
return;
|
|
}
|
|
|
|
if (!m_firstFailureSinceOnline.isValid()) {
|
|
m_firstFailureSinceOnline.start();
|
|
}
|
|
|
|
// In bundled mode, ride the supervisor: hint Reconnecting until the
|
|
// child re-spawns or we exhaust retries (which sets Offline directly).
|
|
const int boot = (m_mode == Mode::Bundled) ? kBootProbeMaxMs : 0;
|
|
if (m_firstFailureSinceOnline.hasExpired(m_offlineThresholdMs + boot)) {
|
|
setState(ConnectionState::Offline);
|
|
} else {
|
|
setState(ConnectionState::Reconnecting);
|
|
}
|
|
}
|
|
|
|
void BackendConnection::setState(ConnectionState s)
|
|
{
|
|
if (m_state == s) return;
|
|
m_state = s;
|
|
emit connectionStateChanged();
|
|
}
|
|
|
|
void BackendConnection::setError(const QString& msg)
|
|
{
|
|
if (m_error == msg) return;
|
|
m_error = msg;
|
|
emit errorChanged();
|
|
}
|
|
|
|
void BackendConnection::setUrl(const QString& url)
|
|
{
|
|
if (m_url == url) return;
|
|
m_url = url;
|
|
emit urlChanged();
|
|
}
|
|
|
|
void BackendConnection::setToken(const QString& token)
|
|
{
|
|
if (m_token == token) return;
|
|
m_token = token;
|
|
emit tokenChanged();
|
|
}
|
|
|
|
QString BackendConnection::randomSecret(int bytes)
|
|
{
|
|
QByteArray buf(bytes, Qt::Uninitialized);
|
|
auto* gen = QRandomGenerator::system();
|
|
gen->generate(buf.begin(), buf.end());
|
|
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
|