A phone freezes an app it cannot see, and the front end had nothing holding it: a turn that took minutes -- a long shell command, a provider that thinks, a build -- stopped quietly the moment the user pressed home, and the answer was only there if the window happened to come back. The engine's own /attach flow made this visible: an APK turn survived, a pocket did not. - app/android/src/xyz/whcself/cstagent/CstService.java: a foreground service started with an explicit component (nothing outside the app can reach it, no intent filter) for as long as a turn runs. Being a foreground service makes the process one Android neither freezes nor kills; a partial wake lock (30 min ceiling) keeps the CPU awake with the screen off. Its ongoing notification says what is running -- "CST-agent is working" plus the first line of the prompt -- on the same cst-agent channel the notification_send tool uses, and taps back into the app. A phone that refuses the foreground start (Android 12+ does, from the background) loses the protection, not the turn: the failure is caught and the service stops itself. - Manifest: FOREGROUND_SERVICE, FOREGROUND_SERVICE_DATA_SYNC (API 34+ wants the typed one) and WAKE_LOCK, plus the <service> with foregroundServiceType="dataSync". The first build of this failed with "Expected '>', but got ' '" -- a manifest comment cannot contain "--". - Front end: app::holdForeground(text) / app::releaseForeground() in ToolProvider.h, one call for every platform -- Android starts and stops the service through JNI, a desktop has nothing to hold (its window is the app). AgentCore::send() calls it when the turn starts (firstLine() of the prompt is what the notification says) and finishTurn() releases it, so the notification exists exactly as long as the work does. CST_APP_NO_SERVICE=1 keeps a test run from touching the phone. - On the NX789J, with the app sent home and the screen off: the service read back isForeground=true with its notification, the wake lock cst-agent:turn was taken at the start of the turn and dropped at its end, run_shell "sleep 60" finished with the screen dark, and the service was gone after. Desktop: build clean, --uitest all green, --selftest and cst-app-selftest OK (the hold is a no-op there, so nothing about the Qt side changed). Version 0.9.4.
161 lines
5.4 KiB
C++
161 lines
5.4 KiB
C++
/* Platform tools on Linux, implemented by the app (not by the engine): they
|
|
* exist exactly where the desktop service does, and the engine only ever sees
|
|
* the capability -- platforms + probe decide whether the tool is offered. */
|
|
|
|
#include <QByteArray>
|
|
#include <QClipboard>
|
|
#include <QDesktopServices>
|
|
#include <QFileInfo>
|
|
#include <QGuiApplication>
|
|
#include <QProcess>
|
|
#include <QString>
|
|
#include <QStringList>
|
|
#include <QUrl>
|
|
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
|
|
#include "core/AgentCore.h"
|
|
#include "core/ToolProvider.h"
|
|
|
|
namespace app {
|
|
namespace {
|
|
|
|
/* whereis-style check without a shell: the same rule the engine uses for the
|
|
* clipboard helpers. */
|
|
bool haveProgram(const char *name) {
|
|
QByteArray path = qgetenv("PATH");
|
|
const QList<QByteArray> dirs = path.split(':');
|
|
for (const QByteArray &d : dirs) {
|
|
QByteArray dir = d.isEmpty() ? QByteArray(".") : d;
|
|
QByteArray cand = dir + "/" + name;
|
|
if (QFileInfo::exists(QString::fromLocal8Bit(cand)) &&
|
|
QFileInfo(QString::fromLocal8Bit(cand)).isExecutable())
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* 0 when notifications can be shown; -1 with the reason otherwise. */
|
|
int notifyProbe(char *why, size_t why_sz) {
|
|
if (haveProgram("notify-send"))
|
|
return 0;
|
|
if (haveProgram("gdbus") && !qgetenv("DBUS_SESSION_BUS_ADDRESS").isEmpty())
|
|
return 0;
|
|
snprintf(why, why_sz,
|
|
"no notification service (no notify-send, no D-Bus session bus)");
|
|
return -1;
|
|
}
|
|
|
|
const char *argOf(const ctl_tool_call_t *call, const char *key) {
|
|
for (int i = 0; i < call->nargs; i++)
|
|
if (strcmp(call->args[i].key, key) == 0)
|
|
return call->args[i].value;
|
|
return nullptr;
|
|
}
|
|
|
|
/* Runs on the turn's worker thread: no GUI objects here. */
|
|
int notifyRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
|
|
cst_tool_result_t *r, void *ud) {
|
|
(void)ctx;
|
|
(void)ud;
|
|
const char *title = argOf(call, "title");
|
|
const char *message = argOf(call, "message");
|
|
if (!message || !message[0]) {
|
|
const char *msg = "error: notification_send needs a message";
|
|
r->out = strdup(msg);
|
|
r->out_len = r->out ? strlen(msg) : 0;
|
|
r->exit_code = 1;
|
|
return 0;
|
|
}
|
|
QStringList args;
|
|
if (title && title[0])
|
|
args << QString::fromUtf8(title);
|
|
else
|
|
args << QStringLiteral("CST-agent");
|
|
args << QString::fromUtf8(message);
|
|
const int rc = QProcess::execute(QStringLiteral("notify-send"), args);
|
|
const QByteArray text =
|
|
(rc == 0 ? QByteArrayLiteral("notification sent")
|
|
: QByteArrayLiteral("notify-send failed (rc=") +
|
|
QByteArray::number(rc) + QByteArrayLiteral(")"));
|
|
r->out = strdup(text.constData());
|
|
r->out_len = r->out ? strlen(r->out) : 0;
|
|
r->exit_code = rc == 0 ? 0 : 1;
|
|
return 0;
|
|
}
|
|
|
|
/* Registered once; the engine keeps a copy until the owner is unregistered. */
|
|
cst_tool_def_t g_notify;
|
|
|
|
} // namespace
|
|
|
|
/* A file card's "open": xdg-open through QDesktopServices, so the desktop's
|
|
* own application for the type comes up. */
|
|
QString openLocalFile(const QString &path, const QString &title,
|
|
QString *status) {
|
|
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
|
|
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
|
|
if (status)
|
|
*status = QStringLiteral("opened %1 (test mode)").arg(name);
|
|
return QString();
|
|
}
|
|
if (!QFileInfo::exists(path))
|
|
return QStringLiteral("the file is gone: %1").arg(name);
|
|
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(path)))
|
|
return QStringLiteral("no application on this desktop opens %1").arg(name);
|
|
if (status)
|
|
*status = QStringLiteral("opened %1").arg(name);
|
|
return QString();
|
|
}
|
|
|
|
/* No share sheet here: the path is what another program (or another window)
|
|
* needs, so it goes to the clipboard. */
|
|
QString shareLocalFile(const QString &path, const QString &title,
|
|
QString *status) {
|
|
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
|
|
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
|
|
if (status)
|
|
*status = QStringLiteral("shared %1 (test mode)").arg(name);
|
|
return QString();
|
|
}
|
|
if (!QFileInfo::exists(path))
|
|
return QStringLiteral("the file is gone: %1").arg(name);
|
|
QGuiApplication::clipboard()->setText(path);
|
|
if (status)
|
|
*status = QStringLiteral("%1: the path is on the clipboard").arg(name);
|
|
return QString();
|
|
}
|
|
|
|
/* A desktop has nothing to hold: the window is the app, and a turn runs as
|
|
* long as it is open -- the machine does not freeze a process it can see.
|
|
* Kept here so the front end calls one API on every platform. */
|
|
QString holdForeground(const QString &text) {
|
|
(void)text;
|
|
return QString();
|
|
}
|
|
|
|
void releaseForeground() {}
|
|
|
|
int platformRegisterLinux(AgentCore *core) {
|
|
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
|
{"message", "string", 1, "text of the notification"},
|
|
{"title", "string", 0, "title line (default: CST-agent)"},
|
|
{nullptr, nullptr, 0, nullptr},
|
|
{nullptr, nullptr, 0, nullptr},
|
|
{nullptr, nullptr, 0, nullptr},
|
|
{nullptr, nullptr, 0, nullptr}};
|
|
memset(&g_notify, 0, sizeof(g_notify));
|
|
setMeta(&g_notify, "notification_send",
|
|
"Show a desktop notification (what the user sees outside the app).",
|
|
CST_TP_ASK, "notify", kDesktopOnly, notifyProbe);
|
|
memcpy(g_notify.params, params, sizeof(params));
|
|
g_notify.run = nullptr;
|
|
g_notify.run_ex = notifyRun;
|
|
g_notify.ud = nullptr;
|
|
g_notify.owner = "app:linux";
|
|
return core->registerTool(&g_notify) ? 1 : 0;
|
|
}
|
|
|
|
} // namespace app
|