Files
php-qml/framework/qml/src/BackendConnection.cpp

675 lines
23 KiB
C++
Raw Normal View History

#include "BackendConnection.h"
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
#include <QFileInfo>
#include <QLoggingCategory>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
#include <QProcessEnvironment>
#include <QQmlEngine>
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
#include <QRandomGenerator>
#include <QSocketNotifier>
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
#include <QStandardPaths>
#include <QTimer>
#include <QUrl>
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
#include <csignal>
#include <fcntl.h>
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
#include <sys/prctl.h>
#include <unistd.h>
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
namespace PhpQml::Bridge {
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
Q_LOGGING_CATEGORY(lcBundled, "phpqml.bridge.bundled")
namespace {
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
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)
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
, m_appName(QCoreApplication::applicationName().isEmpty()
? QStringLiteral("php-qml-app")
: QCoreApplication::applicationName())
, m_nam(new QNetworkAccessManager(this))
, m_retryTimer(new QTimer(this))
{
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
m_retryTimer->setSingleShot(false);
m_retryTimer->setInterval(kProbeIntervalMs);
connect(m_retryTimer, &QTimer::timeout, this, &BackendConnection::probe);
bundled: SIGTERM the frankenphp child via aboutToQuit, not just the dtor Symptom (user report on v0.1.1): QProcess: Destroyed while process ("/tmp/.mount_Todo-xbNoHHL/usr/bin/frankenphp") is still running. …and the frankenphp child + its PHP workers were left orphaned after the host exited. Cause: teardownChild() was only called from ~BackendConnection. By the time that destructor runs, app.exec() has already returned, QQmlApplicationEngine is mid-destruction, and Qt's event loop is half-torn-down. waitForFinished() doesn't reliably reap the child in that window — QProcess gets destroyed by the QObject parent-chain cleanup before the kernel reports the child as exited. Fix: in BackendConnection's constructor, connect QCoreApplication::aboutToQuit → teardownChild. aboutToQuit fires while the event loop is still active and BEFORE main() starts unwinding the stack, so SIGTERM + waitForFinished can do their job properly. The destructor's teardownChild call stays as belt-and- suspenders (no-op once aboutToQuit has already cleaned up — the function is idempotent via the m_child = nullptr at its end). The connect happens unconditionally in the constructor (not just for bundled mode) because m_child is also nullptr in dev mode and teardownChild handles that with its leading `if (!m_child) return;`. Regression guard: examples/todo/tests/bundled-supervisor.sh gains a "graceful shutdown" step: - Snapshots the host's child PIDs before SIGTERM - SIGTERMs the host, waits up to 3s for clean exit - Greps the host log for "QProcess: Destroyed while" — fail if found - Iterates the snapshotted PIDs, fails on any frankenphp orphan still alive Verified locally: real AppImage + the integration test both clean up without Qt warnings or orphan processes. PLAN.md: new v0.1.2 section above v0.1.1, this is its first entry. CHANGELOG.md: [0.1.2] — TBD section with the same Fixed entry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:49:13 +02:00
// 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.
bundled: SIGTERM the frankenphp child via aboutToQuit, not just the dtor Symptom (user report on v0.1.1): QProcess: Destroyed while process ("/tmp/.mount_Todo-xbNoHHL/usr/bin/frankenphp") is still running. …and the frankenphp child + its PHP workers were left orphaned after the host exited. Cause: teardownChild() was only called from ~BackendConnection. By the time that destructor runs, app.exec() has already returned, QQmlApplicationEngine is mid-destruction, and Qt's event loop is half-torn-down. waitForFinished() doesn't reliably reap the child in that window — QProcess gets destroyed by the QObject parent-chain cleanup before the kernel reports the child as exited. Fix: in BackendConnection's constructor, connect QCoreApplication::aboutToQuit → teardownChild. aboutToQuit fires while the event loop is still active and BEFORE main() starts unwinding the stack, so SIGTERM + waitForFinished can do their job properly. The destructor's teardownChild call stays as belt-and- suspenders (no-op once aboutToQuit has already cleaned up — the function is idempotent via the m_child = nullptr at its end). The connect happens unconditionally in the constructor (not just for bundled mode) because m_child is also nullptr in dev mode and teardownChild handles that with its leading `if (!m_child) return;`. Regression guard: examples/todo/tests/bundled-supervisor.sh gains a "graceful shutdown" step: - Snapshots the host's child PIDs before SIGTERM - SIGTERMs the host, waits up to 3s for clean exit - Greps the host log for "QProcess: Destroyed while" — fail if found - Iterates the snapshotted PIDs, fails on any frankenphp orphan still alive Verified locally: real AppImage + the integration test both clean up without Qt warnings or orphan processes. PLAN.md: new v0.1.2 section above v0.1.1, this is its first entry. CHANGELOG.md: [0.1.2] — TBD section with the same Fixed entry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:49:13 +02:00
if (QCoreApplication::instance()) {
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit,
this, &BackendConnection::teardownChild);
installShutdownSignalRelay();
bundled: SIGTERM the frankenphp child via aboutToQuit, not just the dtor Symptom (user report on v0.1.1): QProcess: Destroyed while process ("/tmp/.mount_Todo-xbNoHHL/usr/bin/frankenphp") is still running. …and the frankenphp child + its PHP workers were left orphaned after the host exited. Cause: teardownChild() was only called from ~BackendConnection. By the time that destructor runs, app.exec() has already returned, QQmlApplicationEngine is mid-destruction, and Qt's event loop is half-torn-down. waitForFinished() doesn't reliably reap the child in that window — QProcess gets destroyed by the QObject parent-chain cleanup before the kernel reports the child as exited. Fix: in BackendConnection's constructor, connect QCoreApplication::aboutToQuit → teardownChild. aboutToQuit fires while the event loop is still active and BEFORE main() starts unwinding the stack, so SIGTERM + waitForFinished can do their job properly. The destructor's teardownChild call stays as belt-and- suspenders (no-op once aboutToQuit has already cleaned up — the function is idempotent via the m_child = nullptr at its end). The connect happens unconditionally in the constructor (not just for bundled mode) because m_child is also nullptr in dev mode and teardownChild handles that with its leading `if (!m_child) return;`. Regression guard: examples/todo/tests/bundled-supervisor.sh gains a "graceful shutdown" step: - Snapshots the host's child PIDs before SIGTERM - SIGTERMs the host, waits up to 3s for clean exit - Greps the host log for "QProcess: Destroyed while" — fail if found - Iterates the snapshotted PIDs, fails on any frankenphp orphan still alive Verified locally: real AppImage + the integration test both clean up without Qt warnings or orphan processes. PLAN.md: new v0.1.2 section above v0.1.1, this is its first entry. CHANGELOG.md: [0.1.2] — TBD section with the same Fixed entry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:49:13 +02:00
}
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
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);
}
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
void BackendConnection::initDevMode()
{
if (m_url.isEmpty()) {
m_url = QStringLiteral("http://127.0.0.1:8765");
}
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
emit urlChanged();
QTimer::singleShot(kInitialProbeMs, this, &BackendConnection::probe);
m_retryTimer->start();
}
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
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");
bundled: wipe Symfony cache on every launch — mount path bakes into cache Reproduces with the v0.1.1 AppImage on the second launch (same user data dir, fresh AppImage mount): phpqml.bridge.bundled: symfony: "/tmp/.mount_Todo-xllnOHH/..." Cannot load migrations from "/tmp/.mount_Todo-xDBkOfG/.../migrations" ^^^^^^^ stale path from PREVIOUS launch's cache Symfony compiles `kernel.project_dir` (an absolute path) into its cached container under var/cache/. We redirect var/cache into the user data dir for read-only-mount survival (v0.1.0 fix), but the *content* of that cache references the mount path that was active when the cache was built. Next launch gets a different /tmp/.mount_<random>; the cached refs are stale; first project_dir-sensitive lookup blows up (doctrine migrations was the canary; would also surface as misrouted assets, broken Twig template paths, etc.). Fix: BackendConnection::initBundledMode does QDir(cacheDir).removeRecursively() right after creating the dirs but before runMigrations spawns the doctrine subprocess. Symfony rebuilds the cache against the current mount on every launch. Cost: ~1-2s of warmup per cold start. Permanent fix is build-time cache warmup (ship the prod cache inside the AppImage, copy to user data dir on first launch, no per-launch warmup) — already tracked as a v0.2.0 item in PLAN.md §13. v0.1.1 takes the simpler always-wipe approach since it's bugfix-class. Regression guard: examples/todo/tests/bundled-supervisor.sh gains a "2nd launch from fresh staging" step that tears down the first host, re-stages a fresh fake AppImage layout (different /tmp dir = different "mount path" from BackendConnection's perspective), and asserts /healthz comes back up. Without the cache wipe, that step would fail exactly the way doctrine did in the user's report. Verified locally: - bundled-supervisor.sh passes (incl. 2nd-launch step) - Real AppImage: two consecutive launches both reach "phpqml.bridge.bundled: migrations OK" + frankenphp spawn Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:23:30 +02:00
// 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();
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
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;
}
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
setState(ConnectionState::Connecting);
QTimer::singleShot(kInitialProbeMs, this, &BackendConnection::probe);
m_retryTimer->start();
}
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
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");
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
}
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
QString BackendConnection::resolveSymfonyDir() const
{
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
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"),
});
bundled: write Symfony cache + log to user data dir (AppImage is read-only) 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>
2026-05-03 12:21:10 +02:00
// 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);
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
bundled: write Symfony cache + log to user data dir (AppImage is read-only) 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>
2026-05-03 12:21:10 +02:00
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);
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
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());
bundled: write Symfony cache + log to user data dir (AppImage is read-only) 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>
2026-05-03 12:21:10 +02:00
// 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);
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
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());
bundled: write Symfony cache + log to user data dir (AppImage is read-only) 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>
2026-05-03 12:21:10 +02:00
env.insert(QStringLiteral("APP_CACHE_DIR"), cacheDir);
env.insert(QStringLiteral("APP_LOG_DIR"), logDir);
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
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);
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
// 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);
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
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;
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
}
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()
{
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
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);
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
connect(m_pendingReply, &QNetworkReply::finished,
this, &BackendConnection::onProbeFinished);
}
void BackendConnection::onProbeFinished()
{
QNetworkReply* reply = m_pendingReply;
m_pendingReply = nullptr;
if (!reply) return;
reply->deleteLater();
Phase 2 sub-commit 3: full Update Semantics + ReactiveListModel + AppShell BackendConnection's ConnectionState enum is now Connecting / Online / Reconnecting / Offline (PLAN.md §5). The probe loop tracks the first failure since the last Online and transitions to Reconnecting on any failed probe, then to Offline once the configurable threshold (30 s default) is exceeded. The Error state is gone; Reconnecting + the exposed `error` string subsume its UI role. ReactiveListModel is the headline QML type: - QAbstractListModel that GETs `baseUrl + source` for an initial JSON array and then keeps in sync via an internal MercureClient subscribed to `topic`. - Role names are derived dynamically from the first row's keys plus an internal `pending` boolean role used by optimistic mutations. - Diff application: upsert (insert-or-update), delete, replace; gap detection via the envelope `version` field with auto re-fetch. - `invoke(method, path, body, optimistic)` is the optimistic command primitive. Generates an Idempotency-Key, applies the local diff, POST/PATCH/DELETEs with that key, and resolves on the matching Mercure echo (correlation-key matched in ModelPublisher's envelope). Rolls back and emits commandFailed on 4xx/5xx, commandTimedOut after 10 s without an echo. Phase 4 packaging will surface configuration for the timeout. AppShell.qml is the optional convenience root: - Reads BackendConnection.connectionState. - Reconnecting → top banner. - Offline → modal overlay with the error string and a Retry button (calls BackendConnection.restart()). - Wraps user content via `default property alias content`. Apps that want full chrome control can skip AppShell entirely; the skeleton's Main.qml keeps its own status display for demonstration and is unaffected. ReactiveObject (single-entity twin of ReactiveListModel) is intentionally deferred — same envelope handling, smaller surface; will land in Phase 2 follow-up or Phase 3 alongside the todo example. Cursor pagination is similarly deferred (the Phase 2 done criterion uses small lists). Smoke tested: /healthz + /api/ping round-trip cleanly, zero Mercure 401s, clean shutdown. composer quality stays green (16 tests, 45 assertions). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:40:12 +02:00
bool ok = false;
if (reply->error() == QNetworkReply::NoError) {
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (status == 200) {
Phase 2 sub-commit 3: full Update Semantics + ReactiveListModel + AppShell BackendConnection's ConnectionState enum is now Connecting / Online / Reconnecting / Offline (PLAN.md §5). The probe loop tracks the first failure since the last Online and transitions to Reconnecting on any failed probe, then to Offline once the configurable threshold (30 s default) is exceeded. The Error state is gone; Reconnecting + the exposed `error` string subsume its UI role. ReactiveListModel is the headline QML type: - QAbstractListModel that GETs `baseUrl + source` for an initial JSON array and then keeps in sync via an internal MercureClient subscribed to `topic`. - Role names are derived dynamically from the first row's keys plus an internal `pending` boolean role used by optimistic mutations. - Diff application: upsert (insert-or-update), delete, replace; gap detection via the envelope `version` field with auto re-fetch. - `invoke(method, path, body, optimistic)` is the optimistic command primitive. Generates an Idempotency-Key, applies the local diff, POST/PATCH/DELETEs with that key, and resolves on the matching Mercure echo (correlation-key matched in ModelPublisher's envelope). Rolls back and emits commandFailed on 4xx/5xx, commandTimedOut after 10 s without an echo. Phase 4 packaging will surface configuration for the timeout. AppShell.qml is the optional convenience root: - Reads BackendConnection.connectionState. - Reconnecting → top banner. - Offline → modal overlay with the error string and a Retry button (calls BackendConnection.restart()). - Wraps user content via `default property alias content`. Apps that want full chrome control can skip AppShell entirely; the skeleton's Main.qml keeps its own status display for demonstration and is unaffected. ReactiveObject (single-entity twin of ReactiveListModel) is intentionally deferred — same envelope handling, smaller surface; will land in Phase 2 follow-up or Phase 3 alongside the todo example. Cursor pagination is similarly deferred (the Phase 2 done criterion uses small lists). Smoke tested: /healthz + /api/ping round-trip cleanly, zero Mercure 401s, clean shutdown. composer quality stays green (16 tests, 45 assertions). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:40:12 +02:00
ok = true;
} else {
setError(QStringLiteral("/healthz returned HTTP %1").arg(status));
}
} else {
setError(reply->errorString());
}
Phase 2 sub-commit 3: full Update Semantics + ReactiveListModel + AppShell BackendConnection's ConnectionState enum is now Connecting / Online / Reconnecting / Offline (PLAN.md §5). The probe loop tracks the first failure since the last Online and transitions to Reconnecting on any failed probe, then to Offline once the configurable threshold (30 s default) is exceeded. The Error state is gone; Reconnecting + the exposed `error` string subsume its UI role. ReactiveListModel is the headline QML type: - QAbstractListModel that GETs `baseUrl + source` for an initial JSON array and then keeps in sync via an internal MercureClient subscribed to `topic`. - Role names are derived dynamically from the first row's keys plus an internal `pending` boolean role used by optimistic mutations. - Diff application: upsert (insert-or-update), delete, replace; gap detection via the envelope `version` field with auto re-fetch. - `invoke(method, path, body, optimistic)` is the optimistic command primitive. Generates an Idempotency-Key, applies the local diff, POST/PATCH/DELETEs with that key, and resolves on the matching Mercure echo (correlation-key matched in ModelPublisher's envelope). Rolls back and emits commandFailed on 4xx/5xx, commandTimedOut after 10 s without an echo. Phase 4 packaging will surface configuration for the timeout. AppShell.qml is the optional convenience root: - Reads BackendConnection.connectionState. - Reconnecting → top banner. - Offline → modal overlay with the error string and a Retry button (calls BackendConnection.restart()). - Wraps user content via `default property alias content`. Apps that want full chrome control can skip AppShell entirely; the skeleton's Main.qml keeps its own status display for demonstration and is unaffected. ReactiveObject (single-entity twin of ReactiveListModel) is intentionally deferred — same envelope handling, smaller surface; will land in Phase 2 follow-up or Phase 3 alongside the todo example. Cursor pagination is similarly deferred (the Phase 2 done criterion uses small lists). Smoke tested: /healthz + /api/ping round-trip cleanly, zero Mercure 401s, clean shutdown. composer quality stays green (16 tests, 45 assertions). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:40:12 +02:00
if (ok) {
setError(QString());
m_firstFailureSinceOnline.invalidate();
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
m_supervisorRetries = 0;
Phase 2 sub-commit 3: full Update Semantics + ReactiveListModel + AppShell BackendConnection's ConnectionState enum is now Connecting / Online / Reconnecting / Offline (PLAN.md §5). The probe loop tracks the first failure since the last Online and transitions to Reconnecting on any failed probe, then to Offline once the configurable threshold (30 s default) is exceeded. The Error state is gone; Reconnecting + the exposed `error` string subsume its UI role. ReactiveListModel is the headline QML type: - QAbstractListModel that GETs `baseUrl + source` for an initial JSON array and then keeps in sync via an internal MercureClient subscribed to `topic`. - Role names are derived dynamically from the first row's keys plus an internal `pending` boolean role used by optimistic mutations. - Diff application: upsert (insert-or-update), delete, replace; gap detection via the envelope `version` field with auto re-fetch. - `invoke(method, path, body, optimistic)` is the optimistic command primitive. Generates an Idempotency-Key, applies the local diff, POST/PATCH/DELETEs with that key, and resolves on the matching Mercure echo (correlation-key matched in ModelPublisher's envelope). Rolls back and emits commandFailed on 4xx/5xx, commandTimedOut after 10 s without an echo. Phase 4 packaging will surface configuration for the timeout. AppShell.qml is the optional convenience root: - Reads BackendConnection.connectionState. - Reconnecting → top banner. - Offline → modal overlay with the error string and a Retry button (calls BackendConnection.restart()). - Wraps user content via `default property alias content`. Apps that want full chrome control can skip AppShell entirely; the skeleton's Main.qml keeps its own status display for demonstration and is unaffected. ReactiveObject (single-entity twin of ReactiveListModel) is intentionally deferred — same envelope handling, smaller surface; will land in Phase 2 follow-up or Phase 3 alongside the todo example. Cursor pagination is similarly deferred (the Phase 2 done criterion uses small lists). Smoke tested: /healthz + /api/ping round-trip cleanly, zero Mercure 401s, clean shutdown. composer quality stays green (16 tests, 45 assertions). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:40:12 +02:00
setState(ConnectionState::Online);
return;
}
if (!m_firstFailureSinceOnline.isValid()) {
m_firstFailureSinceOnline.start();
}
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
// 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)) {
Phase 2 sub-commit 3: full Update Semantics + ReactiveListModel + AppShell BackendConnection's ConnectionState enum is now Connecting / Online / Reconnecting / Offline (PLAN.md §5). The probe loop tracks the first failure since the last Online and transitions to Reconnecting on any failed probe, then to Offline once the configurable threshold (30 s default) is exceeded. The Error state is gone; Reconnecting + the exposed `error` string subsume its UI role. ReactiveListModel is the headline QML type: - QAbstractListModel that GETs `baseUrl + source` for an initial JSON array and then keeps in sync via an internal MercureClient subscribed to `topic`. - Role names are derived dynamically from the first row's keys plus an internal `pending` boolean role used by optimistic mutations. - Diff application: upsert (insert-or-update), delete, replace; gap detection via the envelope `version` field with auto re-fetch. - `invoke(method, path, body, optimistic)` is the optimistic command primitive. Generates an Idempotency-Key, applies the local diff, POST/PATCH/DELETEs with that key, and resolves on the matching Mercure echo (correlation-key matched in ModelPublisher's envelope). Rolls back and emits commandFailed on 4xx/5xx, commandTimedOut after 10 s without an echo. Phase 4 packaging will surface configuration for the timeout. AppShell.qml is the optional convenience root: - Reads BackendConnection.connectionState. - Reconnecting → top banner. - Offline → modal overlay with the error string and a Retry button (calls BackendConnection.restart()). - Wraps user content via `default property alias content`. Apps that want full chrome control can skip AppShell entirely; the skeleton's Main.qml keeps its own status display for demonstration and is unaffected. ReactiveObject (single-entity twin of ReactiveListModel) is intentionally deferred — same envelope handling, smaller surface; will land in Phase 2 follow-up or Phase 3 alongside the todo example. Cursor pagination is similarly deferred (the Phase 2 done criterion uses small lists). Smoke tested: /healthz + /api/ping round-trip cleanly, zero Mercure 401s, clean shutdown. composer quality stays green (16 tests, 45 assertions). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:40:12 +02:00
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();
}
Phase 4a sub-commit 1: bundled-mode startup in BackendConnection Auto-detected on construction: - BRIDGE_URL env set → dev mode (today's behaviour, unchanged). - BRIDGE_URL unset → bundled mode: BackendConnection now 1. Resolves the user app data dir (QStandardPaths::AppDataLocation, ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/, var/cache/ exist there. 2. Generates a per-session 32-byte URL-safe token and a 48-byte Mercure JWT secret. 3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n` against the user's DATABASE_URL with a 60s timeout. 4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and a supervisor that re-spawns up to 5 times on unexpected exit. Each restart fires tokenRotated(newToken). Path resolution defaults to applicationDirPath() + bin/frankenphp, applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for AppImage-style layouts. All three are overridable via BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars. Caddyfiles in skeleton + example now use {$VAR:default} substitution for PORT and the Mercure JWT keys, so the same Caddyfile works in both modes. Dev defaults match symfony/.env. restart() in bundled mode re-spawns the child (resets the supervisor counter); in dev mode it stays a probe-only no-op. Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=… BRIDGE_SYMFONY_DIR=… BRIDGE_CADDYFILE=… ./build/qml/todo` (no BRIDGE_URL): bundled mode created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration, spawned FrankenPHP, served /healthz, accepted a POST /api/todos with the per-session bearer. Dev mode (`make dev`) still works unchanged. Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:00:13 +02:00
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));
}
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>
2026-05-02 19:58:02 +02:00
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