diff --git a/kde/CMakeLists.txt b/kde/CMakeLists.txt index 5eb0fd7..22cba01 100644 --- a/kde/CMakeLists.txt +++ b/kde/CMakeLists.txt @@ -120,6 +120,8 @@ add_executable(ollie-gui gui/thememanager.cpp gui/clipboardhelper.cpp gui/plumber.cpp + gui/ninep.cpp + gui/plumbmsg.cpp ) add_dependencies(ollie-gui lib9p_build) target_include_directories(ollie-gui PRIVATE diff --git a/kde/gui/ChatBlockDelegate.qml b/kde/gui/ChatBlockDelegate.qml index ab46341..c5e80dc 100644 --- a/kde/gui/ChatBlockDelegate.qml +++ b/kde/gui/ChatBlockDelegate.qml @@ -207,13 +207,41 @@ Column { selectedTextColor: theme.highlightedTextColor // Acme-style mouse handling: B2=execute, B3=plumb/menu + // B1+B2=cut, B1+B3=paste (chord detection) MouseArea { anchors.fill: parent acceptedButtons: Qt.MiddleButton | Qt.RightButton cursorShape: Qt.IBeamCursor propagateComposedEvents: true + property bool leftHeld: false + + onPressed: function(mouse) { + // Check if this is a chord (B1 already held) + if (mouse.buttons & Qt.LeftButton) { + leftHeld = true + if (mouse.button === Qt.MiddleButton) { + // B1+B2 = cut (snarf) + mainTextEdit.cut() + mouse.accepted = true + return + } else if (mouse.button === Qt.RightButton) { + // B1+B3 = paste + mainTextEdit.paste() + mouse.accepted = true + return + } + } + leftHeld = false + mouse.accepted = false + } + onClicked: function(mouse) { + if (leftHeld) { + leftHeld = false + return + } + if (mouse.button === Qt.MiddleButton) { // B2: Execute selection as prompt var sel = mainTextEdit.selectedText @@ -237,6 +265,10 @@ Column { } } } + + onReleased: { + leftHeld = false + } } property var _menu: null } @@ -326,14 +358,39 @@ Column { selectionColor: theme.highlightColor selectedTextColor: theme.highlightedTextColor - // Acme-style mouse handling + // Acme-style mouse handling with chord support + // B1+B2=cut, B1+B3=paste (chord detection) MouseArea { anchors.fill: parent acceptedButtons: Qt.MiddleButton | Qt.RightButton cursorShape: Qt.IBeamCursor propagateComposedEvents: true + property bool leftHeld: false + + onPressed: function(mouse) { + if (mouse.buttons & Qt.LeftButton) { + leftHeld = true + if (mouse.button === Qt.MiddleButton) { + textEdit.cut() + mouse.accepted = true + return + } else if (mouse.button === Qt.RightButton) { + textEdit.paste() + mouse.accepted = true + return + } + } + leftHeld = false + mouse.accepted = false + } + onClicked: function(mouse) { + if (leftHeld) { + leftHeld = false + return + } + if (mouse.button === Qt.MiddleButton) { var sel = textEdit.selectedText if (sel && sel.trim() !== "") { @@ -354,6 +411,10 @@ Column { } } } + + onReleased: { + leftHeld = false + } } property Menu _menu: null @@ -382,11 +443,20 @@ Column { onTriggered: if (ctxMenu.target) plumber.plumb(ctxMenu.target.selectedText.trim()) } MenuSeparator {} + MenuItem { + text: "Cut (B1+B2)" + enabled: ctxMenu.target ? ctxMenu.target.selectedText.length > 0 : false + onTriggered: if (ctxMenu.target) ctxMenu.target.cut() + } MenuItem { text: "Copy" enabled: ctxMenu.target ? ctxMenu.target.selectedText.length > 0 : false onTriggered: if (ctxMenu.target) ctxMenu.target.copy() } + MenuItem { + text: "Paste (B1+B3)" + onTriggered: if (ctxMenu.target) ctxMenu.target.paste() + } MenuItem { text: "Select All" onTriggered: if (ctxMenu.target) ctxMenu.target.selectAll() diff --git a/kde/gui/ninep.cpp b/kde/gui/ninep.cpp new file mode 100644 index 0000000..12f918b --- /dev/null +++ b/kde/gui/ninep.cpp @@ -0,0 +1,418 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +#include "ninep.h" + +#include + +// 9P2000 message type codes (plan9port fcall.h) +enum { + Tversion = 100, + Rversion = 101, + Tattach = 104, + Rattach = 105, + Rerror = 107, + Twalk = 110, + Rwalk = 111, + Topen = 112, + Ropen = 113, + Tread = 116, + Rread = 117, + Twrite = 118, + Rwrite = 119, + Tclunk = 120, + Rclunk = 121, +}; + +static constexpr uint16_t NOTAG = 0xFFFF; +static constexpr uint32_t NOFID = 0xFFFFFFFFu; +static constexpr uint16_t Tag = 1; +static constexpr int ConnectTimeoutMs = 5000; +static constexpr uint32_t IOHDRSZ_LOCAL = 24; + +// Little-endian field appenders +static void put1(QByteArray &b, uint8_t v) { b.append(char(v)); } +static void put2(QByteArray &b, uint16_t v) +{ + b.append(char(v & 0xFF)); + b.append(char((v >> 8) & 0xFF)); +} +static void put4(QByteArray &b, uint32_t v) +{ + for (int i = 0; i < 4; i++) + b.append(char((v >> (8 * i)) & 0xFF)); +} +static void put8(QByteArray &b, uint64_t v) +{ + for (int i = 0; i < 8; i++) + b.append(char((v >> (8 * i)) & 0xFF)); +} +static void putStr(QByteArray &b, const QByteArray &s) +{ + put2(b, uint16_t(s.size())); + b.append(s); +} + +// Little-endian field reader +namespace { +struct Reader { + const char *p; + int remaining; + bool ok = true; + static Reader of(const QByteArray &b) { return Reader{b.constData(), int(b.size())}; } + uint8_t u1() + { + if (remaining < 1) { ok = false; return 0; } + uint8_t v = uint8_t(*p); + p++; remaining--; + return v; + } + uint16_t u2() + { + if (remaining < 2) { ok = false; return 0; } + uint16_t v = uint8_t(p[0]) | (uint16_t(uint8_t(p[1])) << 8); + p += 2; remaining -= 2; + return v; + } + uint32_t u4() + { + if (remaining < 4) { ok = false; return 0; } + uint32_t v = 0; + for (int i = 0; i < 4; i++) + v |= uint32_t(uint8_t(p[i])) << (8 * i); + p += 4; remaining -= 4; + return v; + } + QByteArray str() + { + uint16_t n = u2(); + if (!ok || remaining < int(n)) { ok = false; return QByteArray(); } + QByteArray s(p, n); + p += n; remaining -= n; + return s; + } +}; +} // namespace + +NineP::NineP() = default; + +NineP::~NineP() +{ + close(); +} + +bool NineP::isConnected() const +{ + return m_sock && m_sock->state() == QLocalSocket::ConnectedState; +} + +void NineP::close() +{ + if (m_sock) { + if (m_sock->state() == QLocalSocket::ConnectedState) + clunk(RootFid); + m_sock->abort(); + delete m_sock; + m_sock = nullptr; + } +} + +bool NineP::readExactly(char *buf, int n) +{ + static constexpr int ReadChunkTimeoutMs = 2000; + int got = 0; + while (got < n) { + if (m_sock->bytesAvailable() == 0) { + if (!m_sock->waitForReadyRead(ReadChunkTimeoutMs)) { + m_error = QStringLiteral("socket read failed: %1").arg(m_sock->errorString()); + return false; + } + } + const qint64 r = m_sock->read(buf + got, n - got); + if (r < 0) { + m_error = QStringLiteral("socket read error: %1").arg(m_sock->errorString()); + return false; + } + got += int(r); + } + return true; +} + +bool NineP::sendMsg(uint8_t type, uint16_t tag, const QByteArray &body) +{ + QByteArray msg; + const uint32_t size = 4 + 1 + 2 + uint32_t(body.size()); + put4(msg, size); + put1(msg, type); + put2(msg, tag); + msg.append(body); + if (m_sock->write(msg) != msg.size()) { + m_error = QStringLiteral("socket write failed: %1").arg(m_sock->errorString()); + return false; + } + if (!m_sock->waitForBytesWritten(ConnectTimeoutMs)) { + m_error = QStringLiteral("socket flush timed out"); + return false; + } + return true; +} + +bool NineP::recvMsg(uint8_t *type, uint16_t *tag, QByteArray *body) +{ + char hdr[7]; + if (!readExactly(hdr, 7)) + return false; + const uint32_t size = uint32_t(uint8_t(hdr[0])) | (uint32_t(uint8_t(hdr[1])) << 8) + | (uint32_t(uint8_t(hdr[2])) << 16) | (uint32_t(uint8_t(hdr[3])) << 24); + if (size < 7) { + m_error = QStringLiteral("9P message too short (%1)").arg(size); + return false; + } + *type = uint8_t(hdr[4]); + *tag = uint16_t(uint8_t(hdr[5]) | (uint16_t(uint8_t(hdr[6])) << 8)); + const int bodyLen = int(size) - 7; + QByteArray b; + b.resize(bodyLen); + if (bodyLen > 0 && !readExactly(b.data(), bodyLen)) + return false; + if (*type == Rerror) { + Reader r = Reader::of(b); + const QByteArray ename = r.str(); + m_error = QStringLiteral("9P Rerror: %1").arg(QString::fromUtf8(ename)); + return false; + } + *body = b; + return true; +} + +bool NineP::connectAndAttach(const QString &socketPath, const QString &user, + const QString &aname) +{ + close(); + m_sock = new QLocalSocket(); + m_sock->connectToServer(socketPath, QLocalSocket::ReadWrite); + if (!m_sock->waitForConnected(ConnectTimeoutMs)) { + m_error = QStringLiteral("connect %1: %2").arg(socketPath, m_sock->errorString()); + delete m_sock; + m_sock = nullptr; + return false; + } + + // Tversion: msize[4] version[s] + { + QByteArray body; + put4(body, m_msize); + putStr(body, QByteArrayLiteral("9P2000")); + if (!sendMsg(Tversion, NOTAG, body)) { + close(); + return false; + } + uint8_t type; + uint16_t tag; + QByteArray rb; + if (!recvMsg(&type, &tag, &rb) || type != Rversion) { + if (m_error.isEmpty()) + m_error = QStringLiteral("unexpected reply to Tversion"); + close(); + return false; + } + Reader r = Reader::of(rb); + const uint32_t srvMsize = r.u4(); + const QByteArray srvVer = r.str(); + if (!r.ok || !srvVer.startsWith("9P2000")) { + m_error = QStringLiteral("server declined 9P2000 (got '%1')") + .arg(QString::fromUtf8(srvVer)); + close(); + return false; + } + if (srvMsize > 0 && srvMsize < m_msize) + m_msize = srvMsize; + } + + // Tattach: fid[4] afid[4] uname[s] aname[s] + { + QByteArray body; + put4(body, RootFid); + put4(body, NOFID); + putStr(body, user.toUtf8()); + putStr(body, aname.toUtf8()); + if (!sendMsg(Tattach, Tag, body)) { + close(); + return false; + } + uint8_t type; + uint16_t tag; + QByteArray rb; + if (!recvMsg(&type, &tag, &rb) || type != Rattach) { + if (m_error.isEmpty()) + m_error = QStringLiteral("unexpected reply to Tattach"); + close(); + return false; + } + } + return true; +} + +bool NineP::walk(uint32_t newFid, const QString &name) +{ + // Twalk: fid[4] newfid[4] nwname[2] nwname*(wname[s]) + QByteArray body; + put4(body, RootFid); + put4(body, newFid); + const QByteArray n = name.toUtf8(); + put2(body, 1); + putStr(body, n); + if (!sendMsg(Twalk, Tag, body)) + return false; + uint8_t type; + uint16_t tag; + QByteArray rb; + if (!recvMsg(&type, &tag, &rb) || type != Rwalk) { + if (m_error.isEmpty()) + m_error = QStringLiteral("walk '%1' failed").arg(name); + return false; + } + Reader r = Reader::of(rb); + const uint16_t nwqid = r.u2(); + if (!r.ok || nwqid != 1) { + m_error = QStringLiteral("walk '%1' did not resolve").arg(name); + return false; + } + return true; +} + +bool NineP::open(uint32_t fid, uint8_t mode) +{ + QByteArray body; + put4(body, fid); + put1(body, mode); + if (!sendMsg(Topen, Tag, body)) + return false; + uint8_t type; + uint16_t tag; + QByteArray rb; + if (!recvMsg(&type, &tag, &rb) || type != Ropen) { + if (m_error.isEmpty()) + m_error = QStringLiteral("open failed"); + return false; + } + return true; +} + +int NineP::write(uint32_t fid, uint64_t offset, const QByteArray &data) +{ + if (uint32_t(data.size()) + 4 + 1 + 2 + 4 + 8 + 4 > m_msize) { + m_error = QStringLiteral("write exceeds msize"); + return -1; + } + QByteArray body; + put4(body, fid); + put8(body, offset); + put4(body, uint32_t(data.size())); + body.append(data); + if (!sendMsg(Twrite, Tag, body)) + return -1; + uint8_t type; + uint16_t tag; + QByteArray rb; + if (!recvMsg(&type, &tag, &rb) || type != Rwrite) { + if (m_error.isEmpty()) + m_error = QStringLiteral("write failed"); + return -1; + } + Reader r = Reader::of(rb); + const uint32_t count = r.u4(); + if (!r.ok) { + m_error = QStringLiteral("malformed Rwrite"); + return -1; + } + return int(count); +} + +int NineP::read(uint32_t fid, uint64_t offset, uint32_t count, QByteArray *out) +{ + if (count > m_msize - IOHDRSZ_LOCAL) + count = m_msize - IOHDRSZ_LOCAL; + QByteArray body; + put4(body, fid); + put8(body, offset); + put4(body, count); + if (!sendMsg(Tread, Tag, body)) + return -1; + uint8_t type; + uint16_t tag; + QByteArray rb; + if (!recvMsg(&type, &tag, &rb) || type != Rread) { + if (m_error.isEmpty()) + m_error = QStringLiteral("read failed"); + return -1; + } + Reader r = Reader::of(rb); + const uint32_t n = r.u4(); + if (!r.ok || int(n) > rb.size() - 4) { + m_error = QStringLiteral("malformed Rread"); + return -1; + } + if (out) + *out = rb.mid(4, int(n)); + return int(n); +} + +bool NineP::beginRead(uint32_t fid, uint64_t offset, uint32_t count) +{ + if (count > m_msize - IOHDRSZ_LOCAL) + count = m_msize - IOHDRSZ_LOCAL; + QByteArray body; + put4(body, fid); + put8(body, offset); + put4(body, count); + return sendMsg(Tread, Tag, body); +} + +int NineP::recvReadReply(QByteArray *out, int firstByteTimeoutMs, bool *timedOut) +{ + if (timedOut) + *timedOut = false; + if (m_sock->bytesAvailable() == 0) { + if (!m_sock->waitForReadyRead(firstByteTimeoutMs)) { + if (m_sock->state() == QLocalSocket::ConnectedState) { + if (timedOut) + *timedOut = true; + return 0; + } + m_error = QStringLiteral("socket read failed: %1").arg(m_sock->errorString()); + return -1; + } + } + uint8_t type; + uint16_t tag; + QByteArray rb; + if (!recvMsg(&type, &tag, &rb) || type != Rread) { + if (m_error.isEmpty()) + m_error = QStringLiteral("read reply failed"); + return -1; + } + Reader r = Reader::of(rb); + const uint32_t n = r.u4(); + if (!r.ok || int(n) > rb.size() - 4) { + m_error = QStringLiteral("malformed Rread"); + return -1; + } + if (out) + *out = rb.mid(4, int(n)); + return int(n); +} + +bool NineP::clunk(uint32_t fid) +{ + QByteArray body; + put4(body, fid); + if (!sendMsg(Tclunk, Tag, body)) + return false; + uint8_t type; + uint16_t tag; + QByteArray rb; + if (!recvMsg(&type, &tag, &rb) || type != Rclunk) + return false; + return true; +} diff --git a/kde/gui/ninep.h b/kde/gui/ninep.h new file mode 100644 index 0000000..1250772 --- /dev/null +++ b/kde/gui/ninep.h @@ -0,0 +1,91 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * + * NineP — a minimal, synchronous 9P2000 client over a Unix-domain socket. + * + * Just enough protocol to talk to a plan9port plumber: version negotiation, + * attach, walk, open, read and write. No auth (plumber accepts NOFID afid), + * no create/remove/stat. All little-endian, matching plan9port fcall.h. + */ +#ifndef OLLIE_NINEP_H +#define OLLIE_NINEP_H + +#include +#include +#include + +class QLocalSocket; + +/*! + * One synchronous 9P2000 session. Not thread-safe; create one per thread. + */ +class NineP +{ +public: + NineP(); + ~NineP(); + + NineP(const NineP &) = delete; + NineP &operator=(const NineP &) = delete; + + /*! + * Connect to \a socketPath, negotiate 9P2000, and attach as \a user. + * Returns true on success. On failure, errorString() explains why. + */ + bool connectAndAttach(const QString &socketPath, const QString &user, + const QString &aname = QString()); + + bool isConnected() const; + void close(); + + /*! + * Walk from the attached root to \a name (single path element) + * binding \a newFid to it. Returns true on success. + */ + bool walk(uint32_t newFid, const QString &name); + + /*! Open \a fid with the given 9P \a mode (OREAD=0, OWRITE=1, ORDWR=2). */ + bool open(uint32_t fid, uint8_t mode); + + /*! + * Write \a data to \a fid at \a offset. Returns bytes accepted, or -1. + */ + int write(uint32_t fid, uint64_t offset, const QByteArray &data); + + /*! + * Read up to \a count bytes from \a fid at \a offset. Returns bytes read + * (0 at EOF), or -1 on error. Blocks until server responds. + */ + int read(uint32_t fid, uint64_t offset, uint32_t count, QByteArray *out); + + /*! + * Send Tread without waiting for reply. Used with recvReadReply() for + * cancellable blocking reads on edit port. + */ + bool beginRead(uint32_t fid, uint64_t offset, uint32_t count); + + /*! + * Wait up to \a firstByteTimeoutMs for Rread reply. Returns byte count + * (>=0), 0 with \a timedOut set when nothing arrived, or -1 on error. + */ + int recvReadReply(QByteArray *out, int firstByteTimeoutMs, bool *timedOut); + + bool clunk(uint32_t fid); + + uint32_t msize() const { return m_msize; } + QString errorString() const { return m_error; } + + enum OpenMode : uint8_t { OREAD = 0, OWRITE = 1, ORDWR = 2 }; + static constexpr uint32_t RootFid = 0; + +private: + bool sendMsg(uint8_t type, uint16_t tag, const QByteArray &body); + bool recvMsg(uint8_t *type, uint16_t *tag, QByteArray *body); + bool readExactly(char *buf, int n); + + QLocalSocket *m_sock = nullptr; + uint32_t m_msize = 8192; + QString m_error; +}; + +#endif diff --git a/kde/gui/plumber.cpp b/kde/gui/plumber.cpp index f8d9c4d..e1b1525 100644 --- a/kde/gui/plumber.cpp +++ b/kde/gui/plumber.cpp @@ -1,94 +1,230 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ #include "plumber.h" -#include -#include + +#include "ninep.h" +#include "plumbmsg.h" + +#include #include -#include +#include +#include -Plumber::Plumber(QObject *parent) - : QObject(parent) +#include + +// Canonicalise the X11 display the way plan9 getns() does: "xxx:0.0" -> "xxx:0" +// and '/' -> '_'. Only the trailing ".0" is stripped. +static QString canonicalDisplay(QString disp) { - // Find plumb binary - check common plan9port locations - QStringList searchPaths = { - "/usr/local/plan9/bin/plumb", - "/usr/lib/plan9/bin/plumb", - QDir::homePath() + "/.local/plan9/bin/plumb", - QDir::homePath() + "/plan9port/bin/plumb" - }; + const int colon = disp.lastIndexOf(QLatin1Char(':')); + if (colon >= 0) { + int p = colon + 1; + while (p < disp.size() && disp[p].isDigit()) + p++; + if (disp.mid(p) == QLatin1String(".0")) + disp.truncate(p); + } + disp.replace(QLatin1Char('/'), QLatin1Char('_')); + return disp; +} - // Also check PLAN9 environment variable - QString plan9 = qEnvironmentVariable("PLAN9"); - if (!plan9.isEmpty()) { - searchPaths.prepend(plan9 + "/bin/plumb"); +QString Plumber::socketPath() +{ + const QByteArray ns = qgetenv("NAMESPACE"); + QString nsDir; + if (!ns.isEmpty()) { + nsDir = QString::fromLocal8Bit(ns); + } else { + const QByteArray disp = qgetenv("DISPLAY"); + if (disp.isEmpty()) + return QString(); // no namespace resolvable + QString user = QString::fromLocal8Bit(qgetenv("USER")); + if (user.isEmpty()) + user = QString::fromLocal8Bit(qgetenv("LOGNAME")); + nsDir = QStringLiteral("/tmp/ns.%1.%2") + .arg(user, canonicalDisplay(QString::fromLocal8Bit(disp))); + } + return nsDir + QStringLiteral("/plumb"); +} + +// Current user name for the 9P attach +static QString currentUser() +{ + QString user = QString::fromLocal8Bit(qgetenv("USER")); + if (user.isEmpty()) + user = QString::fromLocal8Bit(qgetenv("LOGNAME")); + if (user.isEmpty()) + user = QStringLiteral("none"); + return user; +} + +// --------------------------------------------------------------------------- +// PlumbReader: owns a NineP session on the edit port, loops on blocking reads. +// Lives on its own QThread. Emits parsed messages via queued signals so the +// Plumber (GUI thread) can act on them. +// --------------------------------------------------------------------------- +class PlumbReader : public QThread +{ + Q_OBJECT +public: + explicit PlumbReader(const QString &socketPath, QObject *parent = nullptr) + : QThread(parent) + , m_socketPath(socketPath) + { } - for (const QString &path : searchPaths) { - if (QFileInfo::exists(path)) { - m_plumbPath = path; - qDebug() << "Plumber: found plumb at" << m_plumbPath; - break; + void stop() + { + m_stop.storeRelaxed(1); + } + +Q_SIGNALS: + void message(const QString &file, const QString &addr, const QString &wdir); + void failed(const QString &message); + +protected: + void run() override + { + NineP nine; + if (!nine.connectAndAttach(m_socketPath, currentUser())) { + Q_EMIT failed(QStringLiteral("plumb connect: %1").arg(nine.errorString())); + return; + } + static constexpr uint32_t EditFid = 1; + if (!nine.walk(EditFid, QStringLiteral("edit")) || !nine.open(EditFid, NineP::OREAD)) { + Q_EMIT failed(QStringLiteral("plumb open edit: %1").arg(nine.errorString())); + return; + } + + static constexpr int PollMs = 200; + bool pending = false; + while (!m_stop.loadRelaxed()) { + if (!pending) { + if (!nine.beginRead(EditFid, 0, nine.msize())) { + Q_EMIT failed(QStringLiteral("plumb read: %1").arg(nine.errorString())); + break; + } + pending = true; + } + QByteArray buf; + bool timedOut = false; + const int n = nine.recvReadReply(&buf, PollMs, &timedOut); + if (timedOut) + continue; + if (n < 0) { + if (!m_stop.loadRelaxed()) + Q_EMIT failed(QStringLiteral("plumb read: %1").arg(nine.errorString())); + break; + } + pending = false; + if (n == 0) + continue; + PlumbMsg m; + if (PlumbMsg::unpack(buf, &m)) { + Q_EMIT message(QString::fromUtf8(m.data), m.lookup(QStringLiteral("addr")), + m.wdir); + } } } - if (m_plumbPath.isEmpty()) { - qDebug() << "Plumber: plumb binary not found, B3-plumb disabled"; +private: + QString m_socketPath; + QAtomicInt m_stop{0}; +}; + +// --------------------------------------------------------------------------- +// Plumber +// --------------------------------------------------------------------------- +Plumber::Plumber(QObject *parent) + : QObject(parent) +{ +} + +Plumber::~Plumber() +{ + if (!m_reader) + return; + m_reader->stop(); + if (m_reader->wait(3000)) { + delete m_reader; + } else { + connect(m_reader, &QThread::finished, m_reader, &QObject::deleteLater); } + m_reader = nullptr; } bool Plumber::available() const { - return !m_plumbPath.isEmpty(); + const QString path = socketPath(); + if (path.isEmpty()) + return false; + return QFileInfo::exists(path); } -void Plumber::plumb(const QString &text) +bool Plumber::send(const QString &data, const QString &wdir) { - if (text.isEmpty()) return; + return sendTo(data, QString(), wdir); +} - if (!available()) { - emit plumbError("plumb binary not found"); - return; +bool Plumber::sendTo(const QString &data, const QString &dst, const QString &wdir) +{ + if (data.isEmpty()) + return false; + + const QString path = socketPath(); + if (path.isEmpty()) { + m_error = QStringLiteral("no plumb namespace ($NAMESPACE/$DISPLAY unset)"); + Q_EMIT plumbError(m_error); + return false; } - QProcess *proc = new QProcess(this); - connect(proc, QOverload::of(&QProcess::finished), - this, [this, proc](int exitCode, QProcess::ExitStatus status) { - if (exitCode != 0 || status != QProcess::NormalExit) { - QString err = proc->readAllStandardError(); - if (!err.isEmpty()) { - emit plumbError(err); - } - } - proc->deleteLater(); - }); - - // plumb reads from stdin - proc->start(m_plumbPath, QStringList()); - proc->write(text.toUtf8()); - proc->closeWriteChannel(); -} - -void Plumber::plumbTo(const QString &text, const QString &dst) -{ - if (text.isEmpty()) return; - - if (!available()) { - emit plumbError("plumb binary not found"); - return; + NineP nine; + if (!nine.connectAndAttach(path, currentUser())) { + m_error = nine.errorString(); + Q_EMIT plumbError(m_error); + return false; + } + static constexpr uint32_t SendFid = 1; + if (!nine.walk(SendFid, QStringLiteral("send")) || !nine.open(SendFid, NineP::OWRITE)) { + m_error = nine.errorString(); + Q_EMIT plumbError(m_error); + return false; } - QProcess *proc = new QProcess(this); - connect(proc, QOverload::of(&QProcess::finished), - this, [this, proc](int exitCode, QProcess::ExitStatus status) { - if (exitCode != 0 || status != QProcess::NormalExit) { - QString err = proc->readAllStandardError(); - if (!err.isEmpty()) { - emit plumbError(err); - } - } - proc->deleteLater(); - }); + PlumbMsg m; + m.src = QStringLiteral("ollie"); + m.dst = dst; + m.wdir = wdir.isEmpty() ? QDir::currentPath() : wdir; + m.type = QStringLiteral("text"); + m.data = data.toUtf8(); - // plumb -d destination reads from stdin - proc->start(m_plumbPath, QStringList() << "-d" << dst); - proc->write(text.toUtf8()); - proc->closeWriteChannel(); + const QByteArray packed = m.pack(); + const int w = nine.write(SendFid, 0, packed); + if (w != packed.size()) { + m_error = nine.errorString().isEmpty() + ? QStringLiteral("short plumb write (%1/%2)").arg(w).arg(packed.size()) + : nine.errorString(); + Q_EMIT plumbError(m_error); + return false; + } + m_error.clear(); + return true; } + +void Plumber::startReader() +{ + if (m_reader) + return; + const QString path = socketPath(); + if (path.isEmpty()) { + Q_EMIT readerError(QStringLiteral("no plumb namespace ($NAMESPACE/$DISPLAY unset)")); + return; + } + m_reader = new PlumbReader(path); + connect(m_reader, &PlumbReader::message, this, &Plumber::edit, Qt::QueuedConnection); + connect(m_reader, &PlumbReader::failed, this, &Plumber::readerError, Qt::QueuedConnection); + m_reader->start(); +} + +#include "plumber.moc" diff --git a/kde/gui/plumber.h b/kde/gui/plumber.h index 5984f88..99372f2 100644 --- a/kde/gui/plumber.h +++ b/kde/gui/plumber.h @@ -1,11 +1,30 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Plumber — ollie-gui's bridge to the plan9port plumbing system. + * + * Full plumb client: + * - SEND: send() opens the "send" port on the plumber and writes a packed + * plumb message; the plumber's rules resolve it (file:line -> edit port, + * url -> web port, etc.). The source is "ollie" and the destination is + * left empty so the rules decide. + * - RECEIVE: a dedicated reader thread opens the "edit" port and blocks on + * reads. Each plumb message that arrives (file to open, with an optional + * addr) is emitted via edit() on the GUI thread via a queued signal. + * + * The namespace socket is $NAMESPACE/plumb, or /tmp/ns.$USER.$DISPLAY/plumb + * when $NAMESPACE is unset — the same resolution plan9's getns() performs. + */ #ifndef PLUMBER_H #define PLUMBER_H #include -#include +#include #include -// Plumber wraps plan9port's plumb command for context-aware actions. +class PlumbReader; // reader living on its own thread + +// Plumber wraps plan9port plumbing for context-aware actions. // B3-click on text → plumber routes to appropriate handler (editor, browser, etc.) class Plumber : public QObject { @@ -15,21 +34,53 @@ class Plumber : public QObject public: explicit Plumber(QObject *parent = nullptr); + ~Plumber() override; - // Plumb text to the plan9port plumber daemon - Q_INVOKABLE void plumb(const QString &text); + /*! Resolve the plumb socket path ($NAMESPACE/plumb or display fallback). */ + static QString socketPath(); - // Plumb with explicit source/destination - Q_INVOKABLE void plumbTo(const QString &text, const QString &dst); + /*! + * Build and send a plumb message carrying \a data (a token, path or URL) + * with working directory \a wdir. Returns true if the plumber accepted the + * write. On failure errorString() explains why. + */ + Q_INVOKABLE bool send(const QString &data, const QString &wdir = QString()); - // Check if plumber is available + /*! Plumb text to a specific destination port. */ + Q_INVOKABLE bool sendTo(const QString &data, const QString &dst, const QString &wdir = QString()); + + /*! Last send() error. */ + Q_INVOKABLE QString errorString() const { return m_error; } + + /*! Check if plumber namespace is available. */ Q_INVOKABLE bool available() const; -signals: + // Legacy compatibility aliases + Q_INVOKABLE void plumb(const QString &text) { send(text); } + Q_INVOKABLE void plumbTo(const QString &text, const QString &dst) { sendTo(text, dst); } + + /*! + * Start the edit-port reader thread. Safe to call once; further calls are + * no-ops. Reader failures are reported via readerError(). + */ + Q_INVOKABLE void startReader(); + +Q_SIGNALS: + /*! + * A plumb message arrived on the edit port: open \a file and, if \a addr is + * non-empty, jump to it. Emitted on the GUI (Plumber's) thread. + */ + void edit(const QString &file, const QString &addr, const QString &wdir); + + /*! The reader stopped with an error (e.g. plumber not running). */ + void readerError(const QString &message); + + /*! Send failed. */ void plumbError(const QString &error); private: - QString m_plumbPath; + PlumbReader *m_reader = nullptr; + QString m_error; }; #endif // PLUMBER_H diff --git a/kde/gui/plumbmsg.cpp b/kde/gui/plumbmsg.cpp new file mode 100644 index 0000000..07e6acf --- /dev/null +++ b/kde/gui/plumbmsg.cpp @@ -0,0 +1,170 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +#include "plumbmsg.h" + +QString PlumbMsg::lookup(const QString &name) const +{ + for (const PlumbAttr &a : attr) { + if (a.name == name) + return a.value; + } + return QString(); +} + +void PlumbMsg::setAttr(const QString &name, const QString &value) +{ + for (PlumbAttr &a : attr) { + if (a.name == name) { + a.value = value; + return; + } + } + attr.append(PlumbAttr{name, value}); +} + +// Quote one attribute value if it contains space, ', = or tab +static QString quoteAttrValue(const QString &v) +{ + bool needsQuote = false; + for (const QChar c : v) { + if (c == QLatin1Char(' ') || c == QLatin1Char('\'') + || c == QLatin1Char('=') || c == QLatin1Char('\t')) { + needsQuote = true; + break; + } + } + if (!needsQuote) + return v; + QString out; + out.reserve(v.size() + 2); + out.append(QLatin1Char('\'')); + for (const QChar c : v) { + out.append(c); + if (c == QLatin1Char('\'')) + out.append(c); // double it + } + out.append(QLatin1Char('\'')); + return out; +} + +QByteArray packPlumbAttr(const QList &attr) +{ + QString s; + bool first = true; + for (const PlumbAttr &a : attr) { + if (!first) + s.append(QLatin1Char(' ')); + first = false; + s.append(a.name); + s.append(QLatin1Char('=')); + s.append(quoteAttrValue(a.value)); + } + return s.toUtf8(); +} + +QList unpackPlumbAttr(const QByteArray &line) +{ + QList out; + const QString s = QString::fromUtf8(line); + int i = 0; + const int n = s.size(); + while (i < n) { + while (i < n && (s[i] == QLatin1Char(' ') || s[i] == QLatin1Char('\t'))) + i++; + if (i >= n) + break; + int nameStart = i; + while (i < n && s[i] != QLatin1Char('=') && s[i] != QLatin1Char(' ') + && s[i] != QLatin1Char('\t')) + i++; + if (i >= n || s[i] != QLatin1Char('=')) + break; + const QString name = s.mid(nameStart, i - nameStart); + i++; // skip '=' + QString value; + bool quoting = false; + while (i < n) { + const QChar c = s[i]; + if (quoting) { + if (c == QLatin1Char('\'')) { + if (i + 1 < n && s[i + 1] == QLatin1Char('\'')) { + i++; + } else { + quoting = false; + i++; + continue; + } + } + } else { + if (c == QLatin1Char(' ') || c == QLatin1Char('\t')) + break; + if (c == QLatin1Char('\'')) { + quoting = true; + i++; + continue; + } + } + value.append(c); + i++; + } + out.append(PlumbAttr{name, value}); + } + return out; +} + +QByteArray PlumbMsg::pack() const +{ + QByteArray buf; + buf.append(src.toUtf8()); + buf.append('\n'); + buf.append(dst.toUtf8()); + buf.append('\n'); + buf.append(wdir.toUtf8()); + buf.append('\n'); + buf.append(type.toUtf8()); + buf.append('\n'); + buf.append(packPlumbAttr(attr)); + buf.append('\n'); + buf.append(QByteArray::number(data.size())); + buf.append('\n'); + buf.append(data); + return buf; +} + +bool PlumbMsg::unpack(const QByteArray &buf, PlumbMsg *out) +{ + if (!out) + return false; + int pos = 0; + const int n = buf.size(); + auto nextLine = [&](QByteArray *line) -> bool { + int nl = buf.indexOf('\n', pos); + if (nl < 0) + return false; + *line = buf.mid(pos, nl - pos); + pos = nl + 1; + return true; + }; + + QByteArray srcL, dstL, wdirL, typeL, attrL, ndataL; + if (!nextLine(&srcL) || !nextLine(&dstL) || !nextLine(&wdirL) + || !nextLine(&typeL) || !nextLine(&attrL) || !nextLine(&ndataL)) + return false; + + PlumbMsg m; + m.src = QString::fromUtf8(srcL); + m.dst = QString::fromUtf8(dstL); + m.wdir = QString::fromUtf8(wdirL); + m.type = QString::fromUtf8(typeL); + m.attr = unpackPlumbAttr(attrL); + + bool ok = false; + ndataL.trimmed().toInt(&ok); + if (!ok) + return false; + m.data = buf.mid(pos, n - pos); + + *out = m; + return true; +} diff --git a/kde/gui/plumbmsg.h b/kde/gui/plumbmsg.h new file mode 100644 index 0000000..37fb132 --- /dev/null +++ b/kde/gui/plumbmsg.h @@ -0,0 +1,48 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * + * PlumbMsg — a plan9port plumbing message and its wire codec. + * + * Wire format (plan9port libplumb/mesg.c): + * src\n source application ("ollie") + * dst\n destination port ("" lets the rules decide) + * wdir\n working directory (resolves relative paths) + * type\n data type ("text") + * attr\n space-separated name=value attributes + * ndata\n decimal byte count of the data that follows + * the data (NOT newline-terminated) + */ +#ifndef OLLIE_PLUMBMSG_H +#define OLLIE_PLUMBMSG_H + +#include +#include +#include + +struct PlumbAttr { + QString name; + QString value; + bool operator==(const PlumbAttr &o) const + { + return name == o.name && value == o.value; + } +}; + +struct PlumbMsg { + QString src; + QString dst; + QString wdir; + QString type; + QList attr; + QByteArray data; + + QString lookup(const QString &name) const; + void setAttr(const QString &name, const QString &value); + QByteArray pack() const; + static bool unpack(const QByteArray &buf, PlumbMsg *out); +}; + +QByteArray packPlumbAttr(const QList &attr); +QList unpackPlumbAttr(const QByteArray &line); + +#endif