The aboutToQuit-based teardown wired in v0.1.2 only fires when something calls QCoreApplication::quit() — typically a window close. `kill -TERM` to the host process bypasses Qt entirely (no default SIGTERM handler), so teardownChild never ran on signal-driven shutdown. Local tests passed on lucky timing because PR_SET_PDEATHSIG made the kernel SIGTERM frankenphp once the host died, but the timing was racy and surfaced on CI as "frankenphp child PID outlived the host (supervisor didn't clean up)". Fix: install a SIGTERM/SIGINT handler in BackendConnection that uses the self-pipe pattern — the C signal handler writes one byte (the only truly async-signal-safe primitive), a QSocketNotifier on the read end calls QCoreApplication::quit() in the main thread, and aboutToQuit runs the existing teardownChild before app.exec() returns. The host now exits cleanly under `kill -TERM` from service managers, launchers, and the test harness. Also bumps the bundled-supervisor.sh first-relaunch grace from 2s to 3s — teardownChild itself waits up to 2s for frankenphp to finish after SIGTERM, so the host needs ~2.x seconds to exit. The graceful-shutdown step further down was already at 3s. No public-API change; production-correctness fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
675 lines
23 KiB
C++
675 lines
23 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 <QSocketNotifier>
|
|
#include <QStandardPaths>
|
|
#include <QTimer>
|
|
#include <QUrl>
|
|
|
|
#include <csignal>
|
|
#include <fcntl.h>
|
|
#include <sys/prctl.h>
|
|
#include <unistd.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;
|
|
|
|
// Self-pipe used to relay SIGTERM/SIGINT into the Qt event loop. The
|
|
// signal handler can only call async-signal-safe functions, so it just
|
|
// writes one byte to the pipe; a QSocketNotifier on the read end picks
|
|
// it up in the main thread and calls QCoreApplication::quit(), which
|
|
// fires aboutToQuit → teardownChild → frankenphp gets a clean SIGTERM
|
|
// while the event loop is still running. Without this, `kill -TERM`
|
|
// to the host bypasses Qt entirely and the supervisor never gets to
|
|
// reap the child.
|
|
int g_signalPipe[2] = {-1, -1};
|
|
|
|
extern "C" void shutdownSignalHandler(int signum)
|
|
{
|
|
const char b = static_cast<char>(signum & 0xff);
|
|
// write() is async-signal-safe; failure is ignored — best effort.
|
|
[[maybe_unused]] auto _ = ::write(g_signalPipe[1], &b, 1);
|
|
}
|
|
|
|
void installShutdownSignalRelay()
|
|
{
|
|
if (g_signalPipe[0] != -1) return; // already installed
|
|
if (::pipe2(g_signalPipe, O_CLOEXEC | O_NONBLOCK) != 0) {
|
|
qCWarning(lcBundled) << "shutdown signal pipe creation failed; SIGTERM will not run teardownChild cleanly";
|
|
return;
|
|
}
|
|
|
|
// QSocketNotifier needs a parent that outlives any signal delivery.
|
|
// QCoreApplication is the natural anchor.
|
|
auto* notifier = new QSocketNotifier(g_signalPipe[0], QSocketNotifier::Read,
|
|
QCoreApplication::instance());
|
|
QObject::connect(notifier, &QSocketNotifier::activated, [](QSocketDescriptor) {
|
|
char buf[16];
|
|
while (::read(g_signalPipe[0], buf, sizeof(buf)) > 0) {
|
|
// drain — content is just the signum, we don't care which
|
|
}
|
|
if (auto* app = QCoreApplication::instance()) {
|
|
app->quit();
|
|
}
|
|
});
|
|
|
|
struct sigaction sa{};
|
|
sa.sa_handler = &shutdownSignalHandler;
|
|
sigemptyset(&sa.sa_mask);
|
|
sa.sa_flags = SA_RESTART;
|
|
::sigaction(SIGTERM, &sa, nullptr);
|
|
::sigaction(SIGINT, &sa, nullptr);
|
|
}
|
|
} // 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);
|
|
|
|
// aboutToQuit fires while the event loop is still active, before main()
|
|
// starts unwinding the stack. Without this, teardownChild only runs from
|
|
// ~BackendConnection — by then the QQmlEngine is already mid-destruction
|
|
// and Qt warns "QProcess: Destroyed while process is still running".
|
|
//
|
|
// aboutToQuit only fires when something *calls* quit() — Qt does not
|
|
// install a default SIGTERM handler. installShutdownSignalRelay() bridges
|
|
// SIGTERM/SIGINT into a quit() call so `kill -TERM` from a service
|
|
// manager / launcher / test harness goes through the same teardown path
|
|
// as a window-close.
|
|
if (QCoreApplication::instance()) {
|
|
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit,
|
|
this, &BackendConnection::teardownChild);
|
|
installShutdownSignalRelay();
|
|
}
|
|
|
|
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");
|
|
// Wipe Symfony cache: kernel.project_dir bakes the AppImage FUSE mount path
|
|
// (different every launch), so cache from a previous launch is always stale.
|
|
QDir(m_dataDir + "/var/cache").removeRecursively();
|
|
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
|