This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-kde/plasmoid/plugin/olliedbusclient.cpp

635 lines
21 KiB
C++

#include "olliedbusclient.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QDBusReply>
#include <QDBusConnection>
#include <QDBusServiceWatcher>
#include <QDBusPendingReply>
#include <QDir>
#include <QUrl>
#include <algorithm>
static const char *SERVICE = "org.ollie.SessionManager";
static const char *OBJ_PATH = "/org/ollie/SessionManager";
static const char *IFACE = "org.ollie.SessionManager";
OllieDBusClient::OllieDBusClient(QObject *parent)
: QObject(parent)
{
m_iface = new QDBusInterface(SERVICE, OBJ_PATH, IFACE,
QDBusConnection::sessionBus(), this);
// Watch for service appearing/disappearing
auto *watcher = new QDBusServiceWatcher(SERVICE,
QDBusConnection::sessionBus(),
QDBusServiceWatcher::WatchForRegistration | QDBusServiceWatcher::WatchForUnregistration,
this);
connect(watcher, &QDBusServiceWatcher::serviceRegistered, this, [this]() {
// Recreate the interface — QDBusInterface caches introspection and can
// become stale after the service restarts.
delete m_iface;
m_iface = new QDBusInterface(SERVICE, OBJ_PATH, IFACE,
QDBusConnection::sessionBus(), this);
refreshSessions();
});
connect(watcher, &QDBusServiceWatcher::serviceUnregistered, this, [this]() {
m_sessions.clear();
m_activeSessionId.clear();
m_chatText.clear();
m_chatOffset = 0;
m_activeState = "idle";
m_activeCwd.clear();
m_activeModel.clear();
m_activeBackend.clear();
emit sessionsChanged();
emit activeSessionIdChanged();
emit chatTextChanged();
emit activeStateChanged();
emit activeCwdChanged();
emit activeModelChanged();
emit activeBackendChanged();
});
// Connect D-Bus signals
QDBusConnection bus = QDBusConnection::sessionBus();
bus.connect(SERVICE, OBJ_PATH, IFACE, "SessionCreated",
this, SLOT(onSessionCreated(QString)));
bus.connect(SERVICE, OBJ_PATH, IFACE, "SessionKilled",
this, SLOT(onSessionKilled(QString)));
bus.connect(SERVICE, OBJ_PATH, IFACE, "SessionRenamed",
this, SLOT(onSessionRenamed(QString,QString)));
bus.connect(SERVICE, OBJ_PATH, IFACE, "StateChanged",
this, SLOT(onStateChanged(QString,QString)));
bus.connect(SERVICE, OBJ_PATH, IFACE, "ChatUpdated",
this, SLOT(onChatUpdated(QString,qlonglong,QString)));
bus.connect(SERVICE, OBJ_PATH, IFACE, "ProcessDetached",
this, SLOT(onProcessDetached(QString)));
bus.connect(SERVICE, OBJ_PATH, IFACE, "ProcessExited",
this, SLOT(onProcessExited(QString,int,int)));
bus.connect(SERVICE, OBJ_PATH, IFACE, "ConfigChanged",
this, SLOT(onConfigChanged(QString,QString,QString)));
refreshSessions();
}
void OllieDBusClient::setActiveSessionId(const QString &id)
{
if (m_activeSessionId == id) return;
m_activeSessionId = id;
m_chatText.clear();
m_chatOffset = 0;
// Update state from sessions list
QString newState = "idle";
for (const QVariant &s : m_sessions) {
QVariantMap session = s.toMap();
if (session["id"].toString() == id) {
newState = session["state"].toString();
break;
}
}
if (newState != m_activeState) {
m_activeState = newState;
emit activeStateChanged();
}
emit activeSessionIdChanged();
emit chatTextChanged();
fetchChat();
fetchConfig();
}
// --- Async refresh sessions ---
void OllieDBusClient::refreshSessions()
{
if (m_refreshPending) return; // coalesce rapid-fire signals
m_refreshPending = true;
QDBusPendingCall pending = m_iface->asyncCall("ListSessions");
auto *watcher = new QDBusPendingCallWatcher(pending, this);
connect(watcher, &QDBusPendingCallWatcher::finished,
this, &OllieDBusClient::onRefreshSessionsFinished);
}
void OllieDBusClient::onRefreshSessionsFinished(QDBusPendingCallWatcher *watcher)
{
m_refreshPending = false;
watcher->deleteLater();
QDBusPendingReply<QStringList> reply = *watcher;
if (reply.isError()) return;
processSessionList(reply.value());
}
void OllieDBusClient::processSessionList(const QStringList &entries)
{
m_sessions.clear();
for (const QString &entry : entries) {
QStringList parts = entry.split('\t');
if (parts.size() >= 4) {
QVariantMap session;
session["id"] = parts[0];
session["state"] = parts[1];
session["model"] = parts[2];
session["agent"] = parts[3];
m_sessions.append(session);
}
}
std::sort(m_sessions.begin(), m_sessions.end(), [](const QVariant &a, const QVariant &b) {
return a.toMap()["id"].toString().compare(b.toMap()["id"].toString(), Qt::CaseInsensitive) < 0;
});
emit sessionsChanged();
// Update active state
for (const QVariant &v : m_sessions) {
QVariantMap s = v.toMap();
if (s["id"].toString() == m_activeSessionId) {
QString newState = s["state"].toString();
if (newState != m_activeState) {
m_activeState = newState;
emit activeStateChanged();
}
return;
}
}
// Active session no longer exists
if (!m_activeSessionId.isEmpty() && !m_sessions.isEmpty()) {
setActiveSessionId(m_sessions.first().toMap()["id"].toString());
} else if (m_activeSessionId.isEmpty() && !m_sessions.isEmpty()) {
setActiveSessionId(m_sessions.first().toMap()["id"].toString());
} else if (m_sessions.isEmpty() && !m_activeSessionId.isEmpty()) {
m_activeSessionId.clear();
m_activeState = "idle";
m_chatText.clear();
m_chatOffset = 0;
m_activeCwd.clear();
m_activeModel.clear();
m_activeBackend.clear();
emit activeSessionIdChanged();
emit activeStateChanged();
emit chatTextChanged();
emit activeCwdChanged();
emit activeModelChanged();
emit activeBackendChanged();
}
}
// --- Async fetch chat ---
void OllieDBusClient::fetchChat()
{
if (m_activeSessionId.isEmpty()) return;
if (m_fetchChatPending) return; // don't pile up requests
m_fetchChatPending = true;
QDBusMessage msg = QDBusMessage::createMethodCall(SERVICE, OBJ_PATH, IFACE, "GetChat");
msg << m_activeSessionId << m_chatOffset;
QDBusPendingCall pending = QDBusConnection::sessionBus().asyncCall(msg);
auto *watcher = new QDBusPendingCallWatcher(pending, this);
connect(watcher, &QDBusPendingCallWatcher::finished,
this, &OllieDBusClient::onFetchChatFinished);
}
void OllieDBusClient::onFetchChatFinished(QDBusPendingCallWatcher *watcher)
{
m_fetchChatPending = false;
watcher->deleteLater();
QDBusPendingReply<QString, qlonglong> reply = *watcher;
if (reply.isError()) return;
QString text = reply.argumentAt<0>();
qlonglong newOffset = reply.argumentAt<1>();
if (!text.isEmpty()) {
if (m_chatOffset == 0) {
m_chatText = text;
} else {
m_chatText += text;
}
m_chatOffset = newOffset;
emit chatTextChanged();
}
}
// --- Async fetch config ---
void OllieDBusClient::fetchConfig()
{
if (m_activeSessionId.isEmpty()) {
if (!m_activeCwd.isEmpty()) {
m_activeCwd.clear();
emit activeCwdChanged();
}
return;
}
QDBusMessage msg = QDBusMessage::createMethodCall(SERVICE, OBJ_PATH, IFACE, "GetConfig");
msg << m_activeSessionId;
QDBusPendingCall pending = QDBusConnection::sessionBus().asyncCall(msg);
auto *watcher = new QDBusPendingCallWatcher(pending, this);
connect(watcher, &QDBusPendingCallWatcher::finished,
this, &OllieDBusClient::onFetchConfigFinished);
}
void OllieDBusClient::onFetchConfigFinished(QDBusPendingCallWatcher *watcher)
{
watcher->deleteLater();
QDBusPendingReply<QString> reply = *watcher;
if (reply.isError()) return;
QString config = reply.value();
QString newCwd, newModel, newBackend;
const QStringList lines = config.split('\n', Qt::SkipEmptyParts);
for (const QString &line : lines) {
if (line.startsWith(QLatin1String("cwd=")))
newCwd = line.mid(4);
else if (line.startsWith(QLatin1String("model=")))
newModel = line.mid(6);
else if (line.startsWith(QLatin1String("backend=")))
newBackend = line.mid(8);
}
if (newCwd != m_activeCwd) {
m_activeCwd = newCwd;
emit activeCwdChanged();
}
if (newModel != m_activeModel) {
m_activeModel = newModel;
emit activeModelChanged();
}
if (newBackend != m_activeBackend) {
m_activeBackend = newBackend;
emit activeBackendChanged();
}
}
// --- User-initiated actions (fire-and-forget async) ---
QStringList OllieDBusClient::listAgents()
{
QDBusReply<QStringList> reply = m_iface->call("ListAgents");
if (reply.isValid())
return reply.value();
return {};
}
QString OllieDBusClient::createSession(const QString &cwd, const QString &backend, const QString &model, const QString &name, const QString &agent, const QString &remote)
{
QString dir = cwd.isEmpty() ? QDir::homePath() : cwd;
QString agentName = agent.isEmpty() ? QString("default") : agent;
auto *watcher = new QDBusPendingCallWatcher(
m_iface->asyncCall("CreateSession", dir, backend, model, agentName, QString(), remote),
this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, name](QDBusPendingCallWatcher *w) {
QDBusPendingReply<QString> reply = *w;
if (reply.isError()) {
emit errorOccurred(reply.error().message());
} else if (!name.trimmed().isEmpty()) {
// Rename the newly created session
QString sessionId = reply.value();
m_iface->asyncCall("RenameSession", sessionId, name.trimmed());
}
w->deleteLater();
});
return QString();
}
void OllieDBusClient::createSessionWithDialog()
{
// Use xdg-desktop-portal FileChooser — plasmashell is QGuiApplication,
// so QFileDialog (QtWidgets) cannot be used.
//
// We use handle_token to make the request object path predictable, then
// connect to the Response signal BEFORE making the call. This avoids a
// race where the portal fires Response before we subscribe to the signal.
static int handleSeq = 0;
QString handleToken = QString("ollie_folder_%1").arg(++handleSeq);
// Predict the request object path: /org/freedesktop/portal/desktop/request/<sender>/<handle_token>
QString sender = QDBusConnection::sessionBus().baseService();
sender.replace('.', '_');
if (sender.startsWith(':'))
sender = sender.mid(1);
QString requestPath = QString("/org/freedesktop/portal/desktop/request/%1/%2")
.arg(sender, handleToken);
// Connect to Response BEFORE making the call to avoid race
QDBusConnection::sessionBus().connect(
"org.freedesktop.portal.Desktop",
requestPath,
"org.freedesktop.portal.Request",
"Response",
this,
SLOT(onPortalFolderResponse(uint,QVariantMap)));
QDBusMessage msg = QDBusMessage::createMethodCall(
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.FileChooser",
"OpenFile");
QString parentWindow; // empty — no parent window handle in plasmoid context
QString title = "Choose working directory for new session";
QVariantMap options;
options["directory"] = true;
options["modal"] = true;
options["handle_token"] = handleToken;
msg << parentWindow << title << options;
QDBusPendingCall pending = QDBusConnection::sessionBus().asyncCall(msg);
auto *watcher = new QDBusPendingCallWatcher(pending, this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, requestPath](QDBusPendingCallWatcher *w) {
w->deleteLater();
QDBusPendingReply<QDBusObjectPath> reply = *w;
if (reply.isError()) {
// Clean up the signal connection on failure
QDBusConnection::sessionBus().disconnect(
"org.freedesktop.portal.Desktop",
requestPath,
"org.freedesktop.portal.Request",
"Response",
this,
SLOT(onPortalFolderResponse(uint,QVariantMap)));
}
});
}
void OllieDBusClient::onPortalFolderResponse(uint response, const QVariantMap &results)
{
if (response != 0) return; // 0 = success, 1 = cancelled, 2 = other
QStringList uris = results.value("uris").toStringList();
if (uris.isEmpty()) return;
// Portal returns file:// URIs
QString path = QUrl(uris.first()).toLocalFile();
if (!path.isEmpty()) {
createSession(path);
}
}
QStringList OllieDBusClient::peerList(const QString &sessionId)
{
QDBusReply<QStringList> reply = m_iface->call("PeerList", sessionId);
if (reply.isValid())
return reply.value();
return {};
}
QVariantMap OllieDBusClient::getChat(const QString &sessionId, int offset)
{
QDBusMessage msg = QDBusMessage::createMethodCall(
"org.ollie.SessionManager", "/org/ollie/SessionManager",
"org.ollie.SessionManager", "GetChat");
msg << sessionId << (qlonglong)offset;
QDBusMessage reply = QDBusConnection::sessionBus().call(msg);
QVariantMap result;
if (reply.type() == QDBusMessage::ReplyMessage && reply.arguments().size() >= 2) {
result["text"] = reply.arguments()[0].toString();
result["offset"] = reply.arguments()[1].toInt();
}
return result;
}
QVariantMap OllieDBusClient::getConfig(const QString &sessionId)
{
QDBusReply<QString> reply = m_iface->call("GetConfig", sessionId);
QVariantMap result;
if (reply.isValid()) {
for (const QString &line : reply.value().split('\n')) {
int eq = line.indexOf('=');
if (eq > 0)
result[line.left(eq)] = line.mid(eq + 1);
}
}
return result;
}
QString OllieDBusClient::getState(const QString &sessionId)
{
QDBusReply<QString> reply = m_iface->call("GetState", sessionId);
if (reply.isValid())
return reply.value();
return "idle";
}
bool OllieDBusClient::killSession(const QString &sessionId)
{
if (sessionId == m_activeSessionId) {
m_activeSessionId.clear();
m_chatText.clear();
m_chatOffset = 0;
m_activeState = "idle";
m_activeCwd.clear();
emit activeSessionIdChanged();
emit chatTextChanged();
emit activeStateChanged();
emit activeCwdChanged();
}
m_iface->asyncCall("KillSession", sessionId);
return true;
}
bool OllieDBusClient::renameSession(const QString &sessionId, const QString &newName)
{
QDBusPendingCall pending = m_iface->asyncCall("RenameSession", sessionId, newName);
auto *watcher = new QDBusPendingCallWatcher(pending, this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, sessionId, newName](QDBusPendingCallWatcher *w) {
w->deleteLater();
QDBusPendingReply<bool> reply = *w;
if (reply.isError() || !reply.value()) return;
if (m_activeSessionId == sessionId) {
m_activeSessionId = newName;
emit activeSessionIdChanged();
}
refreshSessions();
});
return true;
}
bool OllieDBusClient::setConfig(const QString &sessionId, const QString &key, const QString &value)
{
if (sessionId == m_activeSessionId && key == "cwd") {
m_activeCwd = value;
emit activeCwdChanged();
}
m_iface->asyncCall("SetConfig", sessionId, key, value);
return true;
}
bool OllieDBusClient::submit(const QString &sessionId, const QString &prompt)
{
m_iface->asyncCall("Submit", sessionId, prompt);
return true;
}
bool OllieDBusClient::interrupt(const QString &sessionId)
{
m_iface->asyncCall("Interrupt", sessionId);
return true;
}
// --- Detached process management ---
bool OllieDBusClient::detachProcess(const QString &sessionId)
{
QDBusReply<bool> reply = m_iface->call("DetachProcess", sessionId);
return reply.isValid() && reply.value();
}
QVariantList OllieDBusClient::listDetached(const QString &sessionId)
{
QDBusReply<QStringList> reply = m_iface->call("ListDetached", sessionId);
if (!reply.isValid()) return {};
QVariantList result;
for (const QString &entry : reply.value()) {
QStringList parts = entry.split('\t');
if (parts.size() >= 4) {
QVariantMap proc;
proc["pid"] = parts[0].toInt();
proc["command"] = parts[1];
proc["started"] = parts[2].toLongLong();
proc["status"] = parts[3];
result.append(proc);
}
}
return result;
}
bool OllieDBusClient::signalDetached(const QString &sessionId, int pid, const QString &signal)
{
QDBusReply<bool> reply = m_iface->call("SignalDetached", sessionId, pid, signal);
return reply.isValid() && reply.value();
}
QString OllieDBusClient::getDetachedOutput(const QString &sessionId, int pid)
{
QDBusReply<QString> reply = m_iface->call("GetDetachedOutput", sessionId, pid);
if (reply.isValid()) return reply.value();
return QString();
}
bool OllieDBusClient::dismissDetached(const QString &sessionId, int pid)
{
QDBusReply<bool> reply = m_iface->call("DismissDetached", sessionId, pid);
return reply.isValid() && reply.value();
}
// --- D-Bus signal handlers ---
void OllieDBusClient::onSessionCreated(const QString &session_id)
{
Q_UNUSED(session_id);
refreshSessions();
}
void OllieDBusClient::onSessionKilled(const QString &session_id)
{
Q_UNUSED(session_id);
refreshSessions();
}
void OllieDBusClient::onSessionRenamed(const QString &old_id, const QString &new_id)
{
if (m_activeSessionId == old_id) {
m_activeSessionId = new_id;
emit activeSessionIdChanged();
}
refreshSessions();
}
void OllieDBusClient::onStateChanged(const QString &session_id, const QString &new_state)
{
if (session_id == m_activeSessionId) {
m_activeState = new_state;
emit activeStateChanged();
}
// Update state in-place without emitting sessionsChanged() — that would
// cause the ListView to recreate all delegates, breaking inline rename.
// The QML state indicator for the active session uses dbus.activeState
// (updated above). For other sessions the dot updates on the next real
// refresh (session created/killed/renamed).
for (int i = 0; i < m_sessions.size(); ++i) {
QVariantMap session = m_sessions[i].toMap();
if (session["id"].toString() == session_id) {
session["state"] = new_state;
m_sessions[i] = session;
emit sessionStateUpdated(session_id, new_state);
return;
}
}
}
void OllieDBusClient::onChatUpdated(const QString &session_id, qlonglong offset, const QString &new_text)
{
// Emit for all panes listening to any session
emit chatAppended(session_id, static_cast<int>(offset), new_text);
if (session_id != m_activeSessionId) return;
// Append directly from the signal — no need to poll
if (offset == m_chatOffset) {
m_chatText += new_text;
m_chatOffset += new_text.toUtf8().size();
emit chatTextChanged();
} else {
// Offset mismatch — do a full fetch
fetchChat();
}
}
void OllieDBusClient::onProcessDetached(const QString &session_id)
{
emit processDetached(session_id);
}
void OllieDBusClient::onProcessExited(const QString &session_id, int pid, int exitCode)
{
emit processExited(session_id, pid, exitCode);
}
void OllieDBusClient::onConfigChanged(const QString &session_id, const QString &key, const QString &value)
{
Q_UNUSED(key);
Q_UNUSED(value);
// Refresh sessions list so the sidebar picks up agent/model changes
refreshSessions();
// If this is the active session, also refresh its config
if (session_id == m_activeSessionId) {
fetchConfig();
}
}
QStringList OllieDBusClient::listBackends()
{
QDBusReply<QStringList> reply = m_iface->call("ListBackends");
if (reply.isValid())
return reply.value();
return {};
}
QStringList OllieDBusClient::listModels(const QString &sessionId)
{
QDBusReply<QStringList> reply = m_iface->call("ListModels", sessionId);
if (reply.isValid())
return reply.value();
return {};
}
QStringList OllieDBusClient::listModelsByBackend(const QString &backend)
{
QDBusReply<QStringList> reply = m_iface->call("ListModelsByBackend", backend);
if (reply.isValid())
return reply.value();
return {};
}