`am start -n <pkg>/org.qtproject.qt.android.bindings.QtActivity --esa applicationArguments "--workspace,/sdcard/x"` left the window on its default workspace on the NX789J (the plain-string extra too), so the extra is read from the intent directly. That is what makes `cst-agent-app --uitest --workspace DIR` reachable on a device with no keyboard -- and adb cannot inject touch into this window either (tap, touchscreen tap, mouse tap and motionevent all do nothing; key events do work), so it is the only way in. Everything degrades to "no arguments": an invalid object, a missing method or a context that is not an activity all come back empty.
340 lines
13 KiB
C++
340 lines
13 KiB
C++
/* cst-agent-app: the Qt front end.
|
|
*
|
|
* cst-agent-app --workspace DIR [--provider p] [--model m] [--api-key k]
|
|
* cst-agent-app --size WxH window geometry (480x900, 1180x800, ...)
|
|
* cst-agent-app --selftest embedding checks, no window, no network
|
|
*
|
|
* The engine runs in this process (see app/README.md); nothing listens on a
|
|
* port and no second process is started. */
|
|
|
|
#include <QCommandLineParser>
|
|
#include <QCoreApplication>
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
#include <QGuiApplication>
|
|
#include <QQmlApplicationEngine>
|
|
#include <QQmlContext>
|
|
#include <QQuickStyle>
|
|
#include <QStandardPaths>
|
|
#include <QTemporaryDir>
|
|
#include <QTextStream>
|
|
#include <QUrl>
|
|
|
|
#if defined(Q_OS_ANDROID)
|
|
#include <QJniObject>
|
|
#include <QtCore/private/qandroidextras_p.h>
|
|
#endif
|
|
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <memory>
|
|
|
|
#include "core/AgentCore.h"
|
|
#include "core/ToolProvider.h"
|
|
#include "ui/UiTest.h"
|
|
#include "ui/SessionImages.h"
|
|
#include "ui/ThemeIcons.h"
|
|
#include "cst_version.h"
|
|
|
|
#if defined(Q_OS_ANDROID)
|
|
/* Where an Android app is allowed to write: its own data directory. There is
|
|
* no usable $HOME, no writable "/" (QDir::currentPath()) and no /tmp, while
|
|
* the engine wants a config dir ($XDG_CONFIG_HOME/cst-agent, os_config_dir())
|
|
* and a scratch dir (the first writable entry of os_temp_dir()'s list). All
|
|
* of them are pointed here -- once, before the engine exists. */
|
|
QString androidRoot() {
|
|
const QString root =
|
|
QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
|
return root.isEmpty() ? QDir::tempPath() + QStringLiteral("/cst-agent")
|
|
: root;
|
|
}
|
|
#endif
|
|
|
|
namespace {
|
|
|
|
int runSelftest() {
|
|
QTextStream out(stdout);
|
|
int bad = 0;
|
|
QTemporaryDir tmp, cfg;
|
|
if (!tmp.isValid() || !cfg.isValid()) {
|
|
out << "selftest: cannot create a scratch dir\n";
|
|
return 1;
|
|
}
|
|
/* The window passes no --api-key, so the key the user saved for the provider
|
|
* has to come back from <config>/cst-agent/credentials -- the file the
|
|
* settings dialog writes. Planted in a scratch config dir, never in the
|
|
* real one. */
|
|
const QString cfgdir = cfg.path() + QStringLiteral("/cst-agent");
|
|
QDir().mkpath(cfgdir);
|
|
QFile creds(cfgdir + QStringLiteral("/credentials"));
|
|
if (!creds.open(QIODevice::WriteOnly | QIODevice::Truncate) ||
|
|
creds.write("deepseek.api_key=selftest-saved\n") < 0) {
|
|
out << "selftest: cannot write a scratch credentials file\n";
|
|
return 1;
|
|
}
|
|
creds.close();
|
|
qputenv("XDG_CONFIG_HOME", cfg.path().toUtf8());
|
|
|
|
AgentCore core;
|
|
QString err;
|
|
if (!core.open(tmp.path(), QStringLiteral("deepseek"), QString(), QString(),
|
|
&err)) {
|
|
out << "selftest: engine did not start: " << err << "\n";
|
|
return 1;
|
|
}
|
|
const cst_agent_config_t *live =
|
|
core.agent() ? cst_agent_config(core.agent()) : nullptr;
|
|
if (!live || strcmp(live->api_key, "selftest-saved") != 0) {
|
|
out << "selftest: the saved key did not reach the engine config\n";
|
|
bad++;
|
|
} else {
|
|
out << "selftest: the saved key is in the engine config\n";
|
|
}
|
|
if (!core.open(tmp.path(), QStringLiteral("deepseek"),
|
|
QStringLiteral("deepseek-flash"), QStringLiteral("test-key"),
|
|
&err)) {
|
|
out << "selftest: engine did not start: " << err << "\n";
|
|
return 1;
|
|
}
|
|
out << "selftest: engine started in-process\n";
|
|
if (core.agent() == nullptr) {
|
|
out << "selftest: no agent handle\n";
|
|
return 1;
|
|
}
|
|
/* the front end's own tools go through the engine's registry */
|
|
const int n = app::registerPlatformTools(&core);
|
|
out << "selftest: platform tools registered: " << n << "\n";
|
|
const cst_tool_def_t *t = cst_tool_find("notification_send");
|
|
if (!t) {
|
|
out << "selftest: notification_send is not in the registry\n";
|
|
bad++;
|
|
} else {
|
|
if (t->owner == nullptr || strncmp(t->owner, "app:", 4) != 0) {
|
|
out << "selftest: notification_send has no app owner\n";
|
|
bad++;
|
|
}
|
|
char why[160] = "";
|
|
const bool ok = cst_tool_available(t, why, sizeof(why)) == 0;
|
|
out << "selftest: notification_send available=" << (ok ? "yes" : "no")
|
|
<< (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why)))
|
|
<< "\n";
|
|
}
|
|
/* The engine's own deliver_file: the file a tool hands to the person. */
|
|
const cst_tool_def_t *dv = cst_tool_find("deliver_file");
|
|
if (!dv) {
|
|
out << "selftest: deliver_file is not in the registry\n";
|
|
bad++;
|
|
} else {
|
|
char why[160] = "";
|
|
const bool ok = cst_tool_available(dv, why, sizeof(why)) == 0;
|
|
out << "selftest: deliver_file available=" << (ok ? "yes" : "no")
|
|
<< (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why)))
|
|
<< "\n";
|
|
}
|
|
/* the mode comes from the engine, not from the UI */
|
|
core.setMode(QStringLiteral("general"));
|
|
if (core.mode() != QLatin1String("general")) {
|
|
out << "selftest: mode did not stick\n";
|
|
bad++;
|
|
}
|
|
app::unregisterPlatformTools(&core);
|
|
if (cst_tool_find("notification_send") != nullptr) {
|
|
out << "selftest: platform tools were not dropped\n";
|
|
bad++;
|
|
}
|
|
core.close();
|
|
out << (bad ? "selftest: FAILED\n" : "selftest: OK\n");
|
|
out.flush();
|
|
return bad ? 1 : 0;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char **argv) {
|
|
/* --selftest stays headless: no window, no QML, no display needed. */
|
|
bool selftest = false;
|
|
for (int i = 1; i < argc; i++)
|
|
if (strcmp(argv[i], "--selftest") == 0)
|
|
selftest = true;
|
|
if (selftest) {
|
|
QCoreApplication app(argc, argv);
|
|
return runSelftest();
|
|
}
|
|
|
|
QGuiApplication app(argc, argv);
|
|
/* The window styles its own controls; the platform style only adds noise
|
|
* here (the KDE Breeze plugin logs a line per registered type and trips
|
|
* over the composer's TextArea). An explicit choice still wins. */
|
|
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE"))
|
|
QQuickStyle::setStyle(QStringLiteral("Basic"));
|
|
QGuiApplication::setApplicationName(QStringLiteral("cst-agent-app"));
|
|
QGuiApplication::setApplicationVersion(QStringLiteral(CST_VERSION));
|
|
QGuiApplication::setOrganizationName(QStringLiteral("cst-agent"));
|
|
|
|
QCommandLineParser parser;
|
|
parser.setApplicationDescription(
|
|
QStringLiteral("CST-agent desktop front end (engine in-process)"));
|
|
parser.addHelpOption();
|
|
parser.addVersionOption();
|
|
QCommandLineOption wsOpt({QStringLiteral("w"), QStringLiteral("workspace")},
|
|
QStringLiteral("workspace directory"),
|
|
QStringLiteral("dir"));
|
|
QCommandLineOption providerOpt(QStringLiteral("provider"),
|
|
QStringLiteral("provider name"),
|
|
QStringLiteral("name"));
|
|
QCommandLineOption modelOpt(QStringLiteral("model"),
|
|
QStringLiteral("model name"), QStringLiteral("name"));
|
|
QCommandLineOption planOpt(QStringLiteral("plan"),
|
|
QStringLiteral("start in plan mode"));
|
|
QCommandLineOption uitestOpt(
|
|
QStringLiteral("uitest"),
|
|
QStringLiteral("drive the window with synthesized mouse/touch input and exit"));
|
|
QCommandLineOption sizeOpt(QStringLiteral("size"),
|
|
QStringLiteral("initial window size, e.g. 480x900"),
|
|
QStringLiteral("WxH"));
|
|
parser.addOption(wsOpt);
|
|
parser.addOption(providerOpt);
|
|
parser.addOption(modelOpt);
|
|
parser.addOption(planOpt);
|
|
parser.addOption(uitestOpt);
|
|
parser.addOption(sizeOpt);
|
|
/* What the process was started with, plus -- on a phone -- what the
|
|
* activity was started with: Qt's own applicationArguments plumbing does
|
|
* not deliver the extra on the NX789J (see activityArguments()), and
|
|
* without it there is no way to hand this window a workspace or a --uitest
|
|
* run on a device that has no keyboard. */
|
|
QStringList args = QCoreApplication::arguments();
|
|
#if defined(Q_OS_ANDROID)
|
|
args += app::activityArguments();
|
|
#endif
|
|
parser.process(args);
|
|
|
|
QString ws = parser.value(wsOpt);
|
|
if (ws.isEmpty()) {
|
|
#if defined(Q_OS_ANDROID)
|
|
ws = androidRoot() + QStringLiteral("/workspace");
|
|
#else
|
|
ws = QDir::currentPath();
|
|
#endif
|
|
}
|
|
ws = QFileInfo(ws).absoluteFilePath();
|
|
|
|
/* --uitest drives a scratch workspace when none was given: the session
|
|
* checks create and delete sessions, which is not something to do in
|
|
* whatever directory the app happened to be started from. */
|
|
std::unique_ptr<QTemporaryDir> scratch;
|
|
if (parser.isSet(uitestOpt) && parser.value(wsOpt).isEmpty()) {
|
|
scratch.reset(new QTemporaryDir);
|
|
if (scratch->isValid()) {
|
|
ws = scratch->path();
|
|
/* settings.json and credentials live in <config>/cst-agent: point them
|
|
* at the scratch dir too, so a test run never rewrites the user's own
|
|
* provider choice or key. */
|
|
qputenv("XDG_CONFIG_HOME", scratch->path().toUtf8());
|
|
}
|
|
}
|
|
|
|
#if defined(Q_OS_ANDROID)
|
|
/* Before the engine is created: its settings, credentials and session store
|
|
* all resolve their paths from the environment. */
|
|
const QString root = androidRoot();
|
|
for (const QString &sub : {QStringLiteral("config"), QStringLiteral("tmp")})
|
|
QDir().mkpath(root + QLatin1Char('/') + sub);
|
|
qputenv("XDG_CONFIG_HOME", (root + QStringLiteral("/config")).toUtf8());
|
|
qputenv("TMPDIR", (root + QStringLiteral("/tmp")).toUtf8());
|
|
qputenv("HOME", root.toUtf8());
|
|
#endif
|
|
|
|
AgentCore core;
|
|
QString err;
|
|
if (!core.open(ws, parser.value(providerOpt), parser.value(modelOpt),
|
|
QString(), &err)) {
|
|
QTextStream(stderr) << "cannot start the engine: " << err << "\n";
|
|
return 1;
|
|
}
|
|
app::registerPlatformTools(&core); /* the app's own tools */
|
|
#if defined(Q_OS_ANDROID)
|
|
/* notification_send sits behind POST_NOTIFICATIONS from Android 13 on, and
|
|
* the tool is hidden while it is not granted (its probe says so): ask once,
|
|
* here, so the tool is there for the first turn rather than a prompt that
|
|
* shows up mid-answer. Denying it only costs the tool. */
|
|
if (QNativeInterface::QAndroidApplication::context().isValid() &&
|
|
QtAndroidPrivate::checkPermission(
|
|
QStringLiteral("android.permission.POST_NOTIFICATIONS"))
|
|
.result() != QtAndroidPrivate::Authorized) {
|
|
QtAndroidPrivate::requestPermission(
|
|
QStringLiteral("android.permission.POST_NOTIFICATIONS"));
|
|
}
|
|
/* "All files access" is the other half of storage: it is what lets the
|
|
* engine read a path in shared storage (a file the user names, not one the
|
|
* picker hands over -- that one is copied into the app either way, see
|
|
* AttachFile.java). Android has no runtime dialog for it, only the Settings
|
|
* screen, so it is opened once (a marker file remembers) and never
|
|
* insisted on: without it everything still works, one picked file at a
|
|
* time. The line below is what logcat shows when a device behaves oddly. */
|
|
const bool allFiles = app::hasAllFilesAccess();
|
|
qInfo("storage: all files access = %s", allFiles ? "yes" : "no");
|
|
if (!allFiles) {
|
|
const QString marker = androidRoot() + QStringLiteral("/.all-files-asked");
|
|
if (!QFileInfo::exists(marker)) {
|
|
const QString why = app::requestAllFilesAccess();
|
|
if (!why.isEmpty())
|
|
qWarning("all files access: %s", qUtf8Printable(why));
|
|
QFile asked(marker);
|
|
if (asked.open(QIODevice::WriteOnly))
|
|
asked.write("1\n");
|
|
}
|
|
}
|
|
#endif
|
|
if (parser.isSet(planOpt))
|
|
core.setPlanMode(true);
|
|
|
|
/* --uitest: same window, driven by synthesized input (app/src/ui/UiTest.cpp). */
|
|
if (parser.isSet(uitestOpt))
|
|
return app::runUiTest(core, parser.value(sizeOpt));
|
|
|
|
/* The layout is responsive; --size is how it gets exercised without a
|
|
* different screen (see app/README.md). */
|
|
int winW = 0, winH = 0;
|
|
const QString size = parser.value(sizeOpt);
|
|
if (!size.isEmpty()) {
|
|
const int x = size.indexOf(QLatin1Char('x'));
|
|
bool okW = false, okH = false;
|
|
if (x > 0) {
|
|
winW = size.left(x).toInt(&okW);
|
|
winH = size.mid(x + 1).toInt(&okH);
|
|
}
|
|
if (!okW || !okH || winW < 320 || winH < 360) {
|
|
QTextStream(stderr) << "--size wants WxH (at least 320x360)\n";
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
QQmlApplicationEngine engine;
|
|
/* image://csttheme/ plus the desktop's icon theme behind it. */
|
|
app::installThemeIcons(&engine);
|
|
/* image://cstattach/: the session's own store, so the pictures a tool made
|
|
* (view_image, an MCP image) and the ones the user attached are shown, not
|
|
* only named. The directory follows the session. */
|
|
app::SessionImages *images = app::installSessionImages(&engine);
|
|
images->setDir(core.attachmentDir());
|
|
QObject::connect(&core, &AgentCore::sessionChanged, images,
|
|
[images, &core] { images->setDir(core.attachmentDir()); });
|
|
engine.rootContext()->setContextProperty(QStringLiteral("agent"), &core);
|
|
engine.rootContext()->setContextProperty(QStringLiteral("initialWidth"), winW);
|
|
engine.rootContext()->setContextProperty(QStringLiteral("initialHeight"), winH);
|
|
const QUrl url(QStringLiteral("qrc:/qt/qml/CstAgent/Main.qml"));
|
|
QObject::connect(
|
|
&engine, &QQmlApplicationEngine::objectCreated, &app,
|
|
[url](QObject *obj, const QUrl &objUrl) {
|
|
if (!obj && url == objUrl)
|
|
QCoreApplication::exit(-1);
|
|
},
|
|
Qt::QueuedConnection);
|
|
engine.load(url);
|
|
if (engine.rootObjects().isEmpty())
|
|
return 1;
|
|
return app.exec();
|
|
}
|