/* 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 #include #include #include #include #include #include #include #include #include #include #include #include #if defined(Q_OS_ANDROID) #include #include #endif #include #include #include #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 /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 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 /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(); }