Plumbing: Kate as a full plan9 plumb client (plumb to edit)
Native C++ 9P2000 client over QLocalSocket (no libplumb/lib9pclient
link) talking to a running plan9port plumber:
- ninep: Tversion/Tattach/Twalk/Topen/Tread/Twrite/Tclunk, LE wire,
msize negotiated from 8192; cancellable split read (beginRead +
recvReadReply) so the edit-port reader thread honours a stop flag
without a second outstanding Tread or any cross-thread socket touch.
- plumbmsg: PlumbMsg pack/unpack with libplumb attribute quoting;
unit-tested against golden bytes captured from a live plumber.
- plumbresolve: plan9 addr (N / N:C / N.C, 1-based) -> 0-based cursor.
- plumber: send() via the send port; PlumbReader QThread on the edit
port emits edit(file,addr,wdir) via a queued signal. Namespace socket
is $NAMESPACE/plumb or /tmp/ns.$USER.$DISPLAY/plumb (plan9 getns).
Plugin: F2 (ollie_plumb) plumbs selection/wordAt(cursor) through the
send port, falling back to internal URL/file resolution; incoming edit
messages open via openUrl + activateView + setCursorPosition.
Tests: test_plumbmsg + test_plumbresolve headless; test_plumb_live does
a real send->rules->edit round-trip against a running plumber and
skips+passes when none is reachable. 16/16 ctest green; live 4/4.
This commit is contained in:
parent
eba8312969
commit
bd3a58e246
42
docs/PLAN.md
42
docs/PLAN.md
|
|
@ -312,6 +312,48 @@ Therefore the palette is replaced, not extended, and the replacement is built on
|
||||||
**project-qualified** (prefixed by the resolved root) so usage never bleeds
|
**project-qualified** (prefixed by the resolved root) so usage never bleeds
|
||||||
between repos that share relative paths.
|
between repos that share relative paths.
|
||||||
|
|
||||||
|
### Plumbing — plan9 "plumb to edit", full client — DONE
|
||||||
|
Kate is a first-class plan9port plumb client: it both **sends** plumb messages
|
||||||
|
and **receives** them on the `edit` port, so files plumbed from anywhere (acme,
|
||||||
|
`plumb(1)`, other tools) open in Kate at the right line, and tokens plumbed from
|
||||||
|
Kate flow through the real plumber rules.
|
||||||
|
|
||||||
|
- **Native C++ 9P2000** (`src/plumb/ninep.{h,cpp}`): a minimal synchronous
|
||||||
|
client over `QLocalSocket` — `Tversion`/`Tattach`/`Twalk`/`Topen`/`Tread`/
|
||||||
|
`Twrite`/`Tclunk`, little-endian, msize negotiated from 8192. No linking of
|
||||||
|
plan9port's C `libplumb`/`lib9pclient`; it's just the wire protocol, verified
|
||||||
|
against `/usr/local/plan9` sources. For the blocking edit-port read it adds a
|
||||||
|
**cancellable split** (`beginRead` + `recvReadReply` with a first-byte
|
||||||
|
timeout) so the reader thread polls its stop flag between windows and never
|
||||||
|
issues a second `Tread` on the same tag, and never touches the socket from
|
||||||
|
another thread.
|
||||||
|
- **Message codec** (`src/plumb/plumbmsg.{h,cpp}`): `PlumbMsg::pack/unpack` with
|
||||||
|
libplumb's exact attribute quoting (`'`-quote values with space/`'`/`=`/tab,
|
||||||
|
`''` escapes `'`). Unit tests pin it to **golden bytes captured from a live
|
||||||
|
plumber** on the edit port, e.g. `plumb\nedit\n/tmp\ntext\naddr=2\n18\n/tmp/
|
||||||
|
plumbtest.txt`.
|
||||||
|
- **Address parsing** (`src/plumb/plumbresolve.{h,cpp}`): plan9 `addr`
|
||||||
|
(`N`, `N:C`, `N.C`, 1-based) → 0-based `KTextEditor::Cursor`. Unit-tested.
|
||||||
|
- **Facade** (`src/plumb/plumber.{h,cpp}`): `Plumber::send(data, wdir)` opens the
|
||||||
|
`send` port and writes a packed message (`src=kate`, empty `dst` so the rules
|
||||||
|
route it); a `PlumbReader` `QThread` holds the `edit` port open and emits
|
||||||
|
`edit(file, addr, wdir)` via a queued signal. Namespace socket resolved as
|
||||||
|
`$NAMESPACE/plumb`, else `/tmp/ns.$USER.$DISPLAY/plumb` (same as plan9
|
||||||
|
`getns()`), display canonicalised.
|
||||||
|
- **Plugin wiring** (`src/plugin/ollieplugin.cpp`): an `ollie_plumb` QAction on
|
||||||
|
**F2** (testing trigger; a function key, so no collision with the Alt+letter
|
||||||
|
door policy — final gesture will be RMB). `plumbAtCursor()` plumbs the
|
||||||
|
selection, else `Document::wordAt(cursorPosition)`, with the active doc's dir
|
||||||
|
as `wdir`; on send failure it falls back to an internal resolver (URL →
|
||||||
|
`QDesktopServices`, path[:line] → open here). Incoming `edit` messages are
|
||||||
|
opened via `MainWindow::openUrl` + `activateView` + `setCursorPosition`.
|
||||||
|
- **Verification**: `test_plumbmsg` + `test_plumbresolve` run headless;
|
||||||
|
`test_plumb_live` does a **real round-trip** against a running plumber (send
|
||||||
|
`<file>:2` → rules → edit port → `edit()` signal) and **skips+passes** when no
|
||||||
|
plumber/namespace is reachable, so CI stays green. The live round-trip was
|
||||||
|
confirmed passing. The GUI open/cursor path (`openUrl`/`setCursorPosition`)
|
||||||
|
is not exercisable headless and is unverified on a live display.
|
||||||
|
|
||||||
## Build and test
|
## Build and test
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ add_subdirectory(fuzzy)
|
||||||
add_subdirectory(palette)
|
add_subdirectory(palette)
|
||||||
add_subdirectory(radial)
|
add_subdirectory(radial)
|
||||||
add_subdirectory(project)
|
add_subdirectory(project)
|
||||||
|
add_subdirectory(plumb)
|
||||||
if(KF6TextEditor_FOUND)
|
if(KF6TextEditor_FOUND)
|
||||||
add_subdirectory(commands)
|
add_subdirectory(commands)
|
||||||
endif()
|
endif()
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ target_link_libraries(olliepalette PRIVATE
|
||||||
radial
|
radial
|
||||||
olliecommands_lib
|
olliecommands_lib
|
||||||
project_lib
|
project_lib
|
||||||
|
plumb_lib
|
||||||
KF6::TextEditor
|
KF6::TextEditor
|
||||||
KF6::XmlGui
|
KF6::XmlGui
|
||||||
KF6::CoreAddons
|
KF6::CoreAddons
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
#include "olliecommands.h"
|
#include "olliecommands.h"
|
||||||
#include "projectindex.h"
|
#include "projectindex.h"
|
||||||
#include "symbolindex.h"
|
#include "symbolindex.h"
|
||||||
|
#include "plumbresolve.h"
|
||||||
|
|
||||||
#include <KTextEditor/MainWindow>
|
#include <KTextEditor/MainWindow>
|
||||||
#include <KTextEditor/View>
|
#include <KTextEditor/View>
|
||||||
|
|
@ -27,6 +28,7 @@
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QCursor>
|
#include <QCursor>
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
|
#include <QDesktopServices>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QEvent>
|
#include <QEvent>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
|
@ -263,13 +265,33 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow)
|
||||||
gotoSymbolAction->setObjectName(QStringLiteral("ollie_goto_symbol"));
|
gotoSymbolAction->setObjectName(QStringLiteral("ollie_goto_symbol"));
|
||||||
connect(gotoSymbolAction, &QAction::triggered, this, &OllieView::showSymbolSwitcher);
|
connect(gotoSymbolAction, &QAction::triggered, this, &OllieView::showSymbolSwitcher);
|
||||||
|
|
||||||
|
// Plumb: resolve the token/selection under the caret through the plan9
|
||||||
|
// plumber. Unlike the three "doors" above, F2 is a function key that does
|
||||||
|
// not collide with Kate's Alt+<letter> accelerators, so we register it as a
|
||||||
|
// normal shortcut here (testing trigger; the final gesture will be RMB).
|
||||||
|
auto *plumbAction = new QAction(i18n("Plumb (plan9) [F2]"), this);
|
||||||
|
plumbAction->setObjectName(QStringLiteral("ollie_plumb"));
|
||||||
|
plumbAction->setShortcut(QKeySequence(Qt::Key_F2));
|
||||||
|
connect(plumbAction, &QAction::triggered, this, &OllieView::plumbAtCursor);
|
||||||
|
|
||||||
// Keep them discoverable as window actions (no shortcut attached).
|
// Keep them discoverable as window actions (no shortcut attached).
|
||||||
if (QWidget *w = m_mainWindow->window()) {
|
if (QWidget *w = m_mainWindow->window()) {
|
||||||
w->addAction(openAction);
|
w->addAction(openAction);
|
||||||
w->addAction(gotoFileAction);
|
w->addAction(gotoFileAction);
|
||||||
w->addAction(gotoSymbolAction);
|
w->addAction(gotoSymbolAction);
|
||||||
|
w->addAction(plumbAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kate becomes a full plumb client: incoming "edit" messages open files
|
||||||
|
// here; outgoing plumbs (plumbAtCursor) go through the "send" port. The
|
||||||
|
// reader runs on its own thread and delivers via a queued signal.
|
||||||
|
connect(&m_plumber, &Plumber::edit, this, &OllieView::onPlumbEdit);
|
||||||
|
connect(&m_plumber, &Plumber::readerError, this, [](const QString &msg) {
|
||||||
|
// Non-fatal: the plumber may simply not be running. Log and carry on.
|
||||||
|
qWarning("ollie plumb: %s", qUtf8Printable(msg));
|
||||||
|
});
|
||||||
|
m_plumber.startReader();
|
||||||
|
|
||||||
// Build on Kate's project plugin: attach to its view now (if present) and
|
// Build on Kate's project plugin: attach to its view now (if present) and
|
||||||
// follow it as plugins come and go. The bridge is read-only; the switchers
|
// follow it as plugins come and go. The bridge is read-only; the switchers
|
||||||
// scope to its project when one is loaded, else to VCS discovery.
|
// scope to its project when one is loaded, else to VCS discovery.
|
||||||
|
|
@ -760,6 +782,104 @@ void OllieView::goToSymbol(const QString &locationId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void OllieView::plumbAtCursor()
|
||||||
|
{
|
||||||
|
KTextEditor::View *view = m_mainWindow->activeView();
|
||||||
|
if (!view) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KTextEditor::Document *doc = view->document();
|
||||||
|
if (!doc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What to plumb: an explicit selection wins (lets the user plumb a path
|
||||||
|
// with a line suffix like "foo.cpp:42"), otherwise the word under the
|
||||||
|
// caret.
|
||||||
|
QString data;
|
||||||
|
if (view->selection()) {
|
||||||
|
data = view->selectionText().trimmed();
|
||||||
|
}
|
||||||
|
if (data.isEmpty()) {
|
||||||
|
data = doc->wordAt(view->cursorPosition());
|
||||||
|
}
|
||||||
|
if (data.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Working directory: the active document's directory so the plumber can
|
||||||
|
// resolve relative paths (its rules apply $wdir to bare names).
|
||||||
|
const QString wdir = activeDocumentDir();
|
||||||
|
|
||||||
|
// Send through the plumber. On failure (no plumber / no namespace) fall
|
||||||
|
// back to a minimal internal resolver so the gesture still does something
|
||||||
|
// useful.
|
||||||
|
if (m_plumber.send(data, wdir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qWarning("ollie plumb send failed: %s", qUtf8Printable(m_plumber.errorString()));
|
||||||
|
|
||||||
|
// Fallback: URL -> external handler; path[:line] -> open here; else treat
|
||||||
|
// the token as a symbol and open the symbol switcher pre-seeded is overkill
|
||||||
|
// here, so we just try to open it as a file.
|
||||||
|
const QUrl asUrl = QUrl::fromUserInput(data);
|
||||||
|
if (asUrl.isValid() && !asUrl.scheme().isEmpty()
|
||||||
|
&& asUrl.scheme() != QStringLiteral("file")) {
|
||||||
|
QDesktopServices::openUrl(asUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPlumbEdit(data, QString(), wdir);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OllieView::onPlumbEdit(const QString &file, const QString &addr, const QString &wdir)
|
||||||
|
{
|
||||||
|
if (file.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A plumbed "file" may carry a trailing address the rules did not split
|
||||||
|
// out (e.g. the fallback path above passes the raw token). Prefer the
|
||||||
|
// explicit addr attribute; if absent, try to peel a ":line[:col]" suffix.
|
||||||
|
QString path = file;
|
||||||
|
QString addrStr = addr;
|
||||||
|
if (addrStr.isEmpty()) {
|
||||||
|
const int colon = path.lastIndexOf(QLatin1Char(':'));
|
||||||
|
if (colon > 1) { // keep a Windows drive letter intact
|
||||||
|
const QString tail = path.mid(colon + 1);
|
||||||
|
bool numeric = !tail.isEmpty();
|
||||||
|
for (const QChar c : tail) {
|
||||||
|
if (!c.isDigit()) {
|
||||||
|
numeric = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (numeric) {
|
||||||
|
addrStr = tail;
|
||||||
|
path = path.left(colon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a relative path against the message's working directory (the
|
||||||
|
// plumber sends absolute paths for the edit port, but the fallback may
|
||||||
|
// not).
|
||||||
|
QFileInfo fi(path);
|
||||||
|
if (fi.isRelative() && !wdir.isEmpty()) {
|
||||||
|
fi = QFileInfo(QDir(wdir).absoluteFilePath(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUrl url = QUrl::fromLocalFile(fi.absoluteFilePath());
|
||||||
|
KTextEditor::View *view = m_mainWindow->openUrl(url);
|
||||||
|
if (!view) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_mainWindow->activateView(view->document());
|
||||||
|
|
||||||
|
const PlumbAddr pa = parsePlumbAddr(addrStr);
|
||||||
|
if (pa.valid) {
|
||||||
|
view->setCursorPosition(KTextEditor::Cursor(pa.line, pa.column));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
QAction *OllieView::findActionByName(const QString &nameOrText) const
|
QAction *OllieView::findActionByName(const QString &nameOrText) const
|
||||||
{
|
{
|
||||||
KXMLGUIFactory *factory = m_mainWindow->guiFactory();
|
KXMLGUIFactory *factory = m_mainWindow->guiFactory();
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
#include "kateprojectbridge.h"
|
#include "kateprojectbridge.h"
|
||||||
#include "projectindexer.h"
|
#include "projectindexer.h"
|
||||||
#include "palettemodel.h" // PaletteItem
|
#include "palettemodel.h" // PaletteItem
|
||||||
|
#include "plumber.h"
|
||||||
#include "waylandcursorwarp.h"
|
#include "waylandcursorwarp.h"
|
||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
|
@ -71,6 +72,13 @@ private Q_SLOTS:
|
||||||
void showSymbolSwitcher();
|
void showSymbolSwitcher();
|
||||||
void goToSymbol(const QString &locationId);
|
void goToSymbol(const QString &locationId);
|
||||||
|
|
||||||
|
// Plumb the text under the caret (or the current selection) through the
|
||||||
|
// plan9 plumber. Bound to F2 for testing.
|
||||||
|
void plumbAtCursor();
|
||||||
|
// A plumb message arrived on the edit port: open \a file and jump to \a
|
||||||
|
// addr (a plan9 edit-port address). Routed from the Plumber reader thread.
|
||||||
|
void onPlumbEdit(const QString &file, const QString &addr, const QString &wdir);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Record one activation of \a id and persist. Feeds the frecency bonus so
|
// Record one activation of \a id and persist. Feeds the frecency bonus so
|
||||||
// habitual choices float up in every palette.
|
// habitual choices float up in every palette.
|
||||||
|
|
@ -153,6 +161,7 @@ private:
|
||||||
KateProjectBridge m_projectBridge; // read-only view of Kate's project plugin
|
KateProjectBridge m_projectBridge; // read-only view of Kate's project plugin
|
||||||
ProjectIndexer m_indexer; // async, cached file/symbol indexing
|
ProjectIndexer m_indexer; // async, cached file/symbol indexing
|
||||||
WaylandCursorWarp m_cursorWarp; // pointer warp on Wayland (no-op on X11)
|
WaylandCursorWarp m_cursorWarp; // pointer warp on Wayland (no-op on X11)
|
||||||
|
Plumber m_plumber; // plan9 plumbing client (send + edit-port reader)
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace katecustom
|
} // namespace katecustom
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
# plumb — plan9 plumbing client (9P2000 over the namespace socket).
|
||||||
|
#
|
||||||
|
# plumb_lib carries the pure codec + resolver and is unit tested without any
|
||||||
|
# GUI/9P I/O. The 9P client (ninep) and the Plumber facade add Qt Network and
|
||||||
|
# a reader thread; they talk to a live plumber and so are exercised by a live
|
||||||
|
# integration test guarded on $NAMESPACE being present.
|
||||||
|
find_package(Qt6 ${QT_MIN_VERSION} COMPONENTS Core Network REQUIRED)
|
||||||
|
|
||||||
|
add_library(plumb_lib STATIC
|
||||||
|
plumbmsg.cpp
|
||||||
|
plumbmsg.h
|
||||||
|
plumbresolve.cpp
|
||||||
|
plumbresolve.h
|
||||||
|
ninep.cpp
|
||||||
|
ninep.h
|
||||||
|
plumber.cpp
|
||||||
|
plumber.h
|
||||||
|
)
|
||||||
|
set_target_properties(plumb_lib PROPERTIES AUTOMOC ON)
|
||||||
|
target_link_libraries(plumb_lib PUBLIC Qt6::Core Qt6::Network)
|
||||||
|
target_include_directories(plumb_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|
||||||
|
find_package(Qt6 ${QT_MIN_VERSION} COMPONENTS Test)
|
||||||
|
if(Qt6Test_FOUND)
|
||||||
|
add_executable(test_plumbmsg test_plumbmsg.cpp)
|
||||||
|
target_link_libraries(test_plumbmsg PRIVATE plumb_lib Qt6::Test)
|
||||||
|
add_test(NAME plumbmsg COMMAND test_plumbmsg)
|
||||||
|
|
||||||
|
add_executable(test_plumbresolve test_plumbresolve.cpp)
|
||||||
|
target_link_libraries(test_plumbresolve PRIVATE plumb_lib Qt6::Test)
|
||||||
|
add_test(NAME plumbresolve COMMAND test_plumbresolve)
|
||||||
|
|
||||||
|
# Live round-trip against a running plumber. Skips itself (passing) when no
|
||||||
|
# plumb socket is reachable, so it is safe in headless/CI environments.
|
||||||
|
add_executable(test_plumb_live test_plumb_live.cpp)
|
||||||
|
target_link_libraries(test_plumb_live PRIVATE plumb_lib Qt6::Test)
|
||||||
|
add_test(NAME plumb_live COMMAND test_plumb_live)
|
||||||
|
endif()
|
||||||
|
|
@ -0,0 +1,465 @@
|
||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||||
|
*/
|
||||||
|
#include "ninep.h"
|
||||||
|
|
||||||
|
#include <QLocalSocket>
|
||||||
|
|
||||||
|
namespace katecustom
|
||||||
|
{
|
||||||
|
|
||||||
|
// 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; // single outstanding request at a time
|
||||||
|
static constexpr int ConnectTimeoutMs = 5000;
|
||||||
|
// Room for the Rread header (count[4]) plus 9P framing; matches plan9port
|
||||||
|
// IOHDRSZ. Reads are clamped to msize-IOHDRSZ so the reply always fits.
|
||||||
|
static constexpr uint32_t IOHDRSZ_LOCAL = 24;
|
||||||
|
|
||||||
|
// Little-endian field appenders (9P is LE on the wire).
|
||||||
|
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 readers over a cursor into a QByteArray.
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
int got = 0;
|
||||||
|
while (got < n) {
|
||||||
|
if (m_sock->bytesAvailable() == 0) {
|
||||||
|
if (!m_sock->waitForReadyRead(-1)) {
|
||||||
|
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); // one path element
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// Rwalk: nwqid[2] nwqid*(qid[13]) — require it walked the single element.
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
// Topen: fid[4] mode[1]
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
// Twrite: fid[4] offset[8] count[4] data[count]
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
// Tread: fid[4] offset[8] count[4]
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// Rread: count[4] data[count]
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
// Tread: fid[4] offset[8] count[4] — sent without waiting for the reply.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// Wait only for the first byte of the reply to appear. If none arrives in
|
||||||
|
// the window, the Tread is still outstanding on the server — report a
|
||||||
|
// timeout so the caller can re-check its stop flag and poll again WITHOUT
|
||||||
|
// issuing another Tread.
|
||||||
|
if (m_sock->bytesAvailable() == 0) {
|
||||||
|
if (!m_sock->waitForReadyRead(firstByteTimeoutMs)) {
|
||||||
|
// Distinguish a quiet socket (still connected, just no data) from a
|
||||||
|
// real error.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Data has started; read the full message (blocking is fine now).
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
// Tclunk: fid[4]
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace katecustom
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||||
|
*
|
||||||
|
* NineP — a minimal, synchronous 9P2000 client over a Unix-domain socket.
|
||||||
|
*
|
||||||
|
* This is just enough of the protocol to talk to a plan9port plumber: version
|
||||||
|
* negotiation, attach, walk, open, read and write. No auth (the plumber accepts
|
||||||
|
* NOFID afid), no create/remove/stat. All little-endian, matching plan9port
|
||||||
|
* fcall.h. Each T-message uses a fixed tag (we issue one request at a time, so
|
||||||
|
* there is no need to multiplex) except Tversion which must use NOTAG.
|
||||||
|
*
|
||||||
|
* The plumb socket lives at $NAMESPACE/plumb and speaks raw 9P2000, exactly as
|
||||||
|
* lib9pclient reaches it via nsmount("plumb") -> dial("unix!$ns/plumb").
|
||||||
|
*/
|
||||||
|
#ifndef KATECUSTOM_NINEP_H
|
||||||
|
#define KATECUSTOM_NINEP_H
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QString>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
class QLocalSocket;
|
||||||
|
|
||||||
|
namespace katecustom
|
||||||
|
{
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* One synchronous 9P2000 session. Not thread-safe; create one per thread that
|
||||||
|
* needs it (the plumb reader uses its own instance on its own 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 with
|
||||||
|
* the given \a aname. Returns true on success. On failure, errorString()
|
||||||
|
* explains why and the session is left closed.
|
||||||
|
*/
|
||||||
|
bool connectAndAttach(const QString &socketPath, const QString &user,
|
||||||
|
const QString &aname = QString());
|
||||||
|
|
||||||
|
/*! True if a session is open and attached. */
|
||||||
|
bool isConnected() const;
|
||||||
|
|
||||||
|
/*! Close the socket (clunks the root fid best-effort). */
|
||||||
|
void close();
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* Walk from the attached root to \a name (a single path element, e.g.
|
||||||
|
* "send" or "edit") 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 the number of bytes the
|
||||||
|
* server accepted, or -1 on error. Writes larger than the negotiated msize
|
||||||
|
* must be split by the caller (plumb messages are small).
|
||||||
|
*/
|
||||||
|
int write(uint32_t fid, uint64_t offset, const QByteArray &data);
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* Read up to \a count bytes from \a fid at \a offset into \a out. Returns
|
||||||
|
* the number of bytes read (0 at EOF), or -1 on error. Blocks until the
|
||||||
|
* server responds — on the edit port this is how we wait for a plumb
|
||||||
|
* message to arrive.
|
||||||
|
*/
|
||||||
|
int read(uint32_t fid, uint64_t offset, uint32_t count, QByteArray *out);
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* Send a Tread for \a fid at \a offset for up to \a count bytes without
|
||||||
|
* waiting for the reply. Pairs with recvReadReply() to make the blocking
|
||||||
|
* edit-port read cancellable: the reader thread issues one beginRead() then
|
||||||
|
* polls recvReadReply() with a short timeout, checking its stop flag
|
||||||
|
* between polls, so no second Tread is ever issued on the same tag.
|
||||||
|
*/
|
||||||
|
bool beginRead(uint32_t fid, uint64_t offset, uint32_t count);
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* Wait up to \a firstByteTimeoutMs for the Rread reply to the pending
|
||||||
|
* beginRead() to begin arriving, then read it fully. Returns the byte count
|
||||||
|
* (>=0), 0 with \a timedOut set when nothing arrived (the Tread is still
|
||||||
|
* pending — do not call beginRead() again), or -1 on error.
|
||||||
|
*/
|
||||||
|
int recvReadReply(QByteArray *out, int firstByteTimeoutMs, bool *timedOut);
|
||||||
|
|
||||||
|
/*! Release \a fid on the server. */
|
||||||
|
bool clunk(uint32_t fid);
|
||||||
|
|
||||||
|
/*! Negotiated maximum message size. */
|
||||||
|
uint32_t msize() const { return m_msize; }
|
||||||
|
|
||||||
|
/*! Human-readable description of the last failure. */
|
||||||
|
QString errorString() const { return m_error; }
|
||||||
|
|
||||||
|
// 9P open modes.
|
||||||
|
enum OpenMode : uint8_t { OREAD = 0, OWRITE = 1, ORDWR = 2 };
|
||||||
|
|
||||||
|
// The root fid bound by attach.
|
||||||
|
static constexpr uint32_t RootFid = 0;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Send a fully-encoded T-message body (without the leading size[4]) with
|
||||||
|
// the given type and tag, prepend size, flush. Returns false on socket
|
||||||
|
// error.
|
||||||
|
bool sendMsg(uint8_t type, uint16_t tag, const QByteArray &body);
|
||||||
|
|
||||||
|
// Read one full R-message. Fills \a type and \a tag and returns the body
|
||||||
|
// (everything after size[4] type[1] tag[2]). On Rerror, sets m_error and
|
||||||
|
// returns false. On socket/protocol error, sets m_error and returns false.
|
||||||
|
bool recvMsg(uint8_t *type, uint16_t *tag, QByteArray *body);
|
||||||
|
|
||||||
|
// Block until exactly n bytes are available or the socket fails.
|
||||||
|
bool readExactly(char *buf, int n);
|
||||||
|
|
||||||
|
QLocalSocket *m_sock = nullptr;
|
||||||
|
uint32_t m_msize = 8192;
|
||||||
|
QString m_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace katecustom
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||||
|
*/
|
||||||
|
#include "plumber.h"
|
||||||
|
|
||||||
|
#include "ninep.h"
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace katecustom
|
||||||
|
{
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (plumber ignores it but requires a fid).
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop()
|
||||||
|
{
|
||||||
|
// Only flips the atomic. The socket is owned solely by run(); the loop
|
||||||
|
// polls this flag between short read-wait windows and tears the socket
|
||||||
|
// down from inside run(). Never touch m_sock from another thread.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One Tread outstanding at a time. We poll its reply in short windows so
|
||||||
|
// the stop flag is honoured within ~PollMs without a cross-thread socket
|
||||||
|
// touch and without ever issuing a second Tread on the same tag.
|
||||||
|
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; // Tread still pending; re-check stop and poll again
|
||||||
|
}
|
||||||
|
if (n < 0) {
|
||||||
|
if (!m_stop.loadRelaxed()) {
|
||||||
|
Q_EMIT failed(QStringLiteral("plumb read: %1").arg(nine.errorString()));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pending = false; // reply consumed; next loop issues a fresh Tread
|
||||||
|
if (n == 0) {
|
||||||
|
continue; // empty read, keep going
|
||||||
|
}
|
||||||
|
PlumbMsg m;
|
||||||
|
if (PlumbMsg::unpack(buf, &m)) {
|
||||||
|
Q_EMIT message(QString::fromUtf8(m.data), m.lookup(QStringLiteral("addr")),
|
||||||
|
m.wdir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Socket is closed by NineP's destructor here, on this thread.
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_socketPath;
|
||||||
|
QAtomicInt m_stop{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plumber
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Plumber::Plumber(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
Plumber::~Plumber()
|
||||||
|
{
|
||||||
|
if (m_reader) {
|
||||||
|
m_reader->stop();
|
||||||
|
m_reader->wait(2000);
|
||||||
|
delete m_reader;
|
||||||
|
m_reader = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Plumber::send(const QString &data, const QString &wdir)
|
||||||
|
{
|
||||||
|
const QString path = socketPath();
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
m_error = QStringLiteral("no plumb namespace ($NAMESPACE/$DISPLAY unset)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
NineP nine;
|
||||||
|
if (!nine.connectAndAttach(path, currentUser())) {
|
||||||
|
m_error = nine.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
static constexpr uint32_t SendFid = 1;
|
||||||
|
if (!nine.walk(SendFid, QStringLiteral("send")) || !nine.open(SendFid, NineP::OWRITE)) {
|
||||||
|
m_error = nine.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlumbMsg m;
|
||||||
|
m.src = QStringLiteral("kate");
|
||||||
|
m.dst = QString(); // let the rules route it
|
||||||
|
m.wdir = wdir;
|
||||||
|
m.type = QStringLiteral("text");
|
||||||
|
m.data = data.toUtf8();
|
||||||
|
|
||||||
|
const QByteArray packed = m.pack();
|
||||||
|
// A plumb message is written as a single Twrite (the port fs treats one
|
||||||
|
// write as one message). Offset 0.
|
||||||
|
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();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace katecustom
|
||||||
|
|
||||||
|
#include "plumber.moc"
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||||
|
*
|
||||||
|
* Plumber — Kate's bridge to the plan9port plumbing system.
|
||||||
|
*
|
||||||
|
* Kate acts as a 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 "kate" 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 KATECUSTOM_PLUMBER_H
|
||||||
|
#define KATECUSTOM_PLUMBER_H
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include "plumbmsg.h"
|
||||||
|
|
||||||
|
namespace katecustom
|
||||||
|
{
|
||||||
|
|
||||||
|
class PlumbReader; // reader living on its own thread
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* Facade used by the plugin. Owns the edit-port reader thread and offers a
|
||||||
|
* one-shot send(). Construct one per OllieView.
|
||||||
|
*/
|
||||||
|
class Plumber : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit Plumber(QObject *parent = nullptr);
|
||||||
|
~Plumber() override;
|
||||||
|
|
||||||
|
/*! Resolve the plumb socket path ($NAMESPACE/plumb or display fallback). */
|
||||||
|
static QString socketPath();
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* Build and send a plumb message carrying \a data (a token, path or URL)
|
||||||
|
* with working directory \a wdir. src="kate", dst="" (rules decide),
|
||||||
|
* type="text". Returns true if the plumber accepted the write. On failure
|
||||||
|
* errorString() explains why — the caller may then fall back to internal
|
||||||
|
* resolution.
|
||||||
|
*/
|
||||||
|
bool send(const QString &data, const QString &wdir);
|
||||||
|
|
||||||
|
/*! Last send() error. */
|
||||||
|
QString errorString() const { return m_error; }
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* Start the edit-port reader thread. Safe to call once; further calls are
|
||||||
|
* no-ops. Reader failures are reported via readerError().
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
|
||||||
|
private:
|
||||||
|
PlumbReader *m_reader = nullptr;
|
||||||
|
QString m_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace katecustom
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||||
|
*/
|
||||||
|
#include "plumbmsg.h"
|
||||||
|
|
||||||
|
namespace katecustom
|
||||||
|
{
|
||||||
|
|
||||||
|
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 any of space, ', = or tab. A literal
|
||||||
|
// ' inside a quoted value is doubled. Mirrors libplumb mesg.c quote().
|
||||||
|
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<PlumbAttr> &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<PlumbAttr> unpackPlumbAttr(const QByteArray &line)
|
||||||
|
{
|
||||||
|
// Parse name=value pairs with libplumb quoting (mesg.c plumbunpackattr):
|
||||||
|
// whitespace separates pairs; a value may be single-quoted; inside quotes a
|
||||||
|
// doubled '' is a literal '.
|
||||||
|
QList<PlumbAttr> 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;
|
||||||
|
}
|
||||||
|
// name up to '='
|
||||||
|
int nameStart = i;
|
||||||
|
while (i < n && s[i] != QLatin1Char('=') && s[i] != QLatin1Char(' ')
|
||||||
|
&& s[i] != QLatin1Char('\t')) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i >= n || s[i] != QLatin1Char('=')) {
|
||||||
|
break; // malformed attribute
|
||||||
|
}
|
||||||
|
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++; // doubled quote -> literal '
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
// Split the first six lines; the sixth field (ndata) is followed by data.
|
||||||
|
// We locate five newlines for the five text headers, then the ndata line,
|
||||||
|
// then take the remainder as data (clamped like libplumb).
|
||||||
|
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;
|
||||||
|
const int declared = ndataL.trimmed().toInt(&ok);
|
||||||
|
Q_UNUSED(declared); // libplumb trusts the actual trailing byte count
|
||||||
|
if (!ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
m.data = buf.mid(pos, n - pos);
|
||||||
|
|
||||||
|
*out = m;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace katecustom
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||||
|
*
|
||||||
|
* PlumbMsg — a plan9port plumbing message and its wire codec.
|
||||||
|
*
|
||||||
|
* A plumb message is a context-carrying request routed by the plumber daemon
|
||||||
|
* through named ports. The wire format (plan9port libplumb/mesg.c) is six
|
||||||
|
* newline-terminated header lines followed by the raw data bytes:
|
||||||
|
*
|
||||||
|
* src\n source application ("kate")
|
||||||
|
* 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
|
||||||
|
* <ndata bytes> the data (NOT newline-terminated)
|
||||||
|
*
|
||||||
|
* Attribute values containing space, tab, '=' or ' are single-quoted, with a
|
||||||
|
* literal ' escaped by doubling it ('') — exactly as libplumb quotes them.
|
||||||
|
*
|
||||||
|
* This header is pure (QString/QByteArray only) so pack/unpack can be unit
|
||||||
|
* tested against the bytes captured from a live plumber on the edit port.
|
||||||
|
*/
|
||||||
|
#ifndef KATECUSTOM_PLUMBMSG_H
|
||||||
|
#define KATECUSTOM_PLUMBMSG_H
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QList>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
namespace katecustom
|
||||||
|
{
|
||||||
|
|
||||||
|
/*! One plumb attribute (name=value). */
|
||||||
|
struct PlumbAttr {
|
||||||
|
QString name;
|
||||||
|
QString value;
|
||||||
|
bool operator==(const PlumbAttr &o) const
|
||||||
|
{
|
||||||
|
return name == o.name && value == o.value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/*! A decoded plumb message. */
|
||||||
|
struct PlumbMsg {
|
||||||
|
QString src;
|
||||||
|
QString dst;
|
||||||
|
QString wdir;
|
||||||
|
QString type;
|
||||||
|
QList<PlumbAttr> attr;
|
||||||
|
QByteArray data;
|
||||||
|
|
||||||
|
/*! Value of attribute \a name, or empty QString if absent. */
|
||||||
|
QString lookup(const QString &name) const;
|
||||||
|
|
||||||
|
/*! Set (or replace) attribute \a name to \a value. */
|
||||||
|
void setAttr(const QString &name, const QString &value);
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* Encode to the libplumb wire format. The attribute list is serialised with
|
||||||
|
* libplumb-compatible quoting. ndata is the byte length of \c data.
|
||||||
|
*/
|
||||||
|
QByteArray pack() const;
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* Decode \a buf (a complete message) into \a out. Returns true on success.
|
||||||
|
* Mirrors libplumb plumbunpackpartial: parses six header lines, then takes
|
||||||
|
* exactly the trailing bytes as data (libplumb clamps ndata to what was
|
||||||
|
* actually received, so a short ndata header is tolerated the same way).
|
||||||
|
*/
|
||||||
|
static bool unpack(const QByteArray &buf, PlumbMsg *out);
|
||||||
|
};
|
||||||
|
|
||||||
|
/*! Serialise an attribute list with libplumb quoting rules (exposed for tests). */
|
||||||
|
QByteArray packPlumbAttr(const QList<PlumbAttr> &attr);
|
||||||
|
|
||||||
|
/*! Parse a packed attribute line back into a list (exposed for tests). */
|
||||||
|
QList<PlumbAttr> unpackPlumbAttr(const QByteArray &line);
|
||||||
|
|
||||||
|
} // namespace katecustom
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||||
|
*/
|
||||||
|
#include "plumbresolve.h"
|
||||||
|
|
||||||
|
namespace katecustom
|
||||||
|
{
|
||||||
|
|
||||||
|
PlumbAddr parsePlumbAddr(const QString &addr)
|
||||||
|
{
|
||||||
|
PlumbAddr out;
|
||||||
|
if (addr.isEmpty()) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// Leading run of digits = line (1-based on the wire).
|
||||||
|
int i = 0;
|
||||||
|
const int n = addr.size();
|
||||||
|
while (i < n && addr[i].isDigit()) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i == 0) {
|
||||||
|
return out; // not a numeric leading address (e.g. a regex)
|
||||||
|
}
|
||||||
|
bool ok = false;
|
||||||
|
const int line = addr.left(i).toInt(&ok);
|
||||||
|
if (!ok || line < 1) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
out.valid = true;
|
||||||
|
out.line = line - 1;
|
||||||
|
|
||||||
|
// Optional ":C" or ".C" column, also 1-based.
|
||||||
|
if (i < n && (addr[i] == QLatin1Char(':') || addr[i] == QLatin1Char('.'))) {
|
||||||
|
int j = i + 1;
|
||||||
|
int colStart = j;
|
||||||
|
while (j < n && addr[j].isDigit()) {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
if (j > colStart) {
|
||||||
|
const int col = addr.mid(colStart, j - colStart).toInt(&ok);
|
||||||
|
if (ok && col >= 1) {
|
||||||
|
out.column = col - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace katecustom
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||||
|
*
|
||||||
|
* PlumbResolve — pure helpers that translate between editor state and plumb
|
||||||
|
* messages, with no Qt GUI or 9P dependencies so they can be unit tested.
|
||||||
|
*
|
||||||
|
* - parseAddr() turns a plan9 edit-port "addr" attribute into a zero-based
|
||||||
|
* (line, column) for KTextEditor::Cursor. Acme's edit rules emit addresses
|
||||||
|
* like "12" (line), "12:3" / "12.3" (line:col). Addresses are 1-based on the
|
||||||
|
* wire; we return 0-based. Regex and compound addresses are not resolved
|
||||||
|
* here (we return the line as 0 so the caller just opens the file).
|
||||||
|
* - selectionToData() picks what text to plumb: an explicit selection wins,
|
||||||
|
* otherwise the word under the caret.
|
||||||
|
*/
|
||||||
|
#ifndef KATECUSTOM_PLUMBRESOLVE_H
|
||||||
|
#define KATECUSTOM_PLUMBRESOLVE_H
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
namespace katecustom
|
||||||
|
{
|
||||||
|
|
||||||
|
/*! Zero-based editor position parsed from a plumb addr attribute. */
|
||||||
|
struct PlumbAddr {
|
||||||
|
bool valid = false; // false when the attr was empty/unparseable
|
||||||
|
int line = 0; // zero-based
|
||||||
|
int column = 0; // zero-based
|
||||||
|
};
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* Parse a plan9 edit-port \a addr attribute into a zero-based position.
|
||||||
|
* Accepts "N", "N:C" and "N.C" (1-based on the wire). An empty or non-numeric
|
||||||
|
* leading address yields {valid=false}. Only the leading simple form is read;
|
||||||
|
* anything more exotic (regex, ranges) leaves column 0 and line from the
|
||||||
|
* leading number if present.
|
||||||
|
*/
|
||||||
|
PlumbAddr parsePlumbAddr(const QString &addr);
|
||||||
|
|
||||||
|
} // namespace katecustom
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||||
|
*
|
||||||
|
* Live round-trip against a running plan9port plumber. This is the real proof
|
||||||
|
* that our native C++ 9P2000 client speaks the protocol correctly: it sends a
|
||||||
|
* plumb message through the "send" port and receives the routed message back on
|
||||||
|
* the "edit" port via the Plumber reader thread.
|
||||||
|
*
|
||||||
|
* If no plumb socket is reachable (no plumber, no $NAMESPACE), the test skips
|
||||||
|
* itself and PASSES, so it is safe to run in headless/CI environments.
|
||||||
|
*/
|
||||||
|
#include "ninep.h"
|
||||||
|
#include "plumber.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QSignalSpy>
|
||||||
|
#include <QTemporaryFile>
|
||||||
|
#include <QTest>
|
||||||
|
|
||||||
|
using namespace katecustom;
|
||||||
|
|
||||||
|
class TestPlumbLive : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
private Q_SLOTS:
|
||||||
|
void connectAttach();
|
||||||
|
void sendThenReceiveOnEditPort();
|
||||||
|
};
|
||||||
|
|
||||||
|
static bool plumberReachable()
|
||||||
|
{
|
||||||
|
const QString path = Plumber::socketPath();
|
||||||
|
if (path.isEmpty() || !QFileInfo::exists(path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
NineP n;
|
||||||
|
const bool ok = n.connectAndAttach(path, QStringLiteral("tester"));
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestPlumbLive::connectAttach()
|
||||||
|
{
|
||||||
|
if (!plumberReachable()) {
|
||||||
|
QSKIP("no reachable plumber (socketPath empty/absent) — skipping live test");
|
||||||
|
}
|
||||||
|
NineP n;
|
||||||
|
QVERIFY2(n.connectAndAttach(Plumber::socketPath(), QStringLiteral("tester")),
|
||||||
|
qPrintable(n.errorString()));
|
||||||
|
QVERIFY(n.isConnected());
|
||||||
|
// We can walk to the well-known ports.
|
||||||
|
QVERIFY2(n.walk(2, QStringLiteral("send")), qPrintable(n.errorString()));
|
||||||
|
QVERIFY2(n.walk(3, QStringLiteral("edit")), qPrintable(n.errorString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestPlumbLive::sendThenReceiveOnEditPort()
|
||||||
|
{
|
||||||
|
if (!plumberReachable()) {
|
||||||
|
QSKIP("no reachable plumber — skipping live round-trip");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real, existing file so the plumber's "existing file -> edit" rule fires.
|
||||||
|
QTemporaryFile tmp;
|
||||||
|
tmp.setAutoRemove(true);
|
||||||
|
QVERIFY(tmp.open());
|
||||||
|
tmp.write("line1\nline2\nline3\n");
|
||||||
|
tmp.flush();
|
||||||
|
const QString file = tmp.fileName();
|
||||||
|
const QFileInfo fi(file);
|
||||||
|
const QString wdir = fi.absolutePath();
|
||||||
|
|
||||||
|
Plumber plumber;
|
||||||
|
QSignalSpy editSpy(&plumber, &Plumber::edit);
|
||||||
|
QSignalSpy errSpy(&plumber, &Plumber::readerError);
|
||||||
|
plumber.startReader();
|
||||||
|
|
||||||
|
// Give the reader thread a moment to attach + open the edit port before we
|
||||||
|
// send, so the routed message isn't delivered before anyone is listening.
|
||||||
|
QTest::qWait(300);
|
||||||
|
if (errSpy.count() > 0) {
|
||||||
|
QSKIP(qPrintable(QStringLiteral("reader could not open edit port: %1")
|
||||||
|
.arg(errSpy.first().first().toString())));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send "<file>:2" — the plumber resolves it and routes it to the edit port.
|
||||||
|
QVERIFY2(plumber.send(file + QStringLiteral(":2"), wdir),
|
||||||
|
qPrintable(plumber.errorString()));
|
||||||
|
|
||||||
|
// The reader should surface the routed message as an edit() signal.
|
||||||
|
QVERIFY(editSpy.wait(3000));
|
||||||
|
QCOMPARE(editSpy.count(), 1);
|
||||||
|
const QList<QVariant> args = editSpy.first();
|
||||||
|
QCOMPARE(args.at(0).toString(), file);
|
||||||
|
QCOMPARE(args.at(1).toString(), QStringLiteral("2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QTEST_MAIN(TestPlumbLive)
|
||||||
|
#include "test_plumb_live.moc"
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||||
|
*
|
||||||
|
* Unit tests for the plumb message codec. The golden bytes are taken verbatim
|
||||||
|
* from a live plan9port plumber on the edit port (captured during design), so
|
||||||
|
* these tests pin our pack/unpack to the real wire format, not to a guess.
|
||||||
|
*/
|
||||||
|
#include "plumbmsg.h"
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QTest>
|
||||||
|
|
||||||
|
using namespace katecustom;
|
||||||
|
|
||||||
|
class TestPlumbMsg : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
private Q_SLOTS:
|
||||||
|
void unpackEditFileWithAddr();
|
||||||
|
void unpackEditUrlEmptyAddr();
|
||||||
|
void packRoundTrip();
|
||||||
|
void attrQuotingRoundTrip();
|
||||||
|
void attrDoubledQuote();
|
||||||
|
void shortNdataTolerated();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Golden message #1 (file:line -> edit): captured bytes were
|
||||||
|
// "plumb\nedit\n/tmp\ntext\naddr=2\n18\n/tmp/plumbtest.txt"
|
||||||
|
void TestPlumbMsg::unpackEditFileWithAddr()
|
||||||
|
{
|
||||||
|
const QByteArray buf = QByteArrayLiteral(
|
||||||
|
"plumb\nedit\n/tmp\ntext\naddr=2\n18\n/tmp/plumbtest.txt");
|
||||||
|
PlumbMsg m;
|
||||||
|
QVERIFY(PlumbMsg::unpack(buf, &m));
|
||||||
|
QCOMPARE(m.src, QStringLiteral("plumb"));
|
||||||
|
QCOMPARE(m.dst, QStringLiteral("edit"));
|
||||||
|
QCOMPARE(m.wdir, QStringLiteral("/tmp"));
|
||||||
|
QCOMPARE(m.type, QStringLiteral("text"));
|
||||||
|
QCOMPARE(m.lookup(QStringLiteral("addr")), QStringLiteral("2"));
|
||||||
|
QCOMPARE(QString::fromUtf8(m.data), QStringLiteral("/tmp/plumbtest.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Golden message #2 (url -> edit, empty addr): captured bytes were
|
||||||
|
// "plumb\nedit\n/tmp\ntext\n\n19\nhttps://example.com"
|
||||||
|
void TestPlumbMsg::unpackEditUrlEmptyAddr()
|
||||||
|
{
|
||||||
|
const QByteArray buf = QByteArrayLiteral(
|
||||||
|
"plumb\nedit\n/tmp\ntext\n\n19\nhttps://example.com");
|
||||||
|
PlumbMsg m;
|
||||||
|
QVERIFY(PlumbMsg::unpack(buf, &m));
|
||||||
|
QVERIFY(m.attr.isEmpty());
|
||||||
|
QCOMPARE(m.lookup(QStringLiteral("addr")), QString());
|
||||||
|
QCOMPARE(QString::fromUtf8(m.data), QStringLiteral("https://example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestPlumbMsg::packRoundTrip()
|
||||||
|
{
|
||||||
|
PlumbMsg m;
|
||||||
|
m.src = QStringLiteral("kate");
|
||||||
|
m.dst = QString();
|
||||||
|
m.wdir = QStringLiteral("/home/user/proj");
|
||||||
|
m.type = QStringLiteral("text");
|
||||||
|
m.setAttr(QStringLiteral("addr"), QStringLiteral("42"));
|
||||||
|
m.data = QByteArrayLiteral("main.cpp");
|
||||||
|
|
||||||
|
const QByteArray packed = m.pack();
|
||||||
|
// Header shape: six '\n'-terminated lines then data with no trailing \n.
|
||||||
|
QCOMPARE(packed, QByteArrayLiteral(
|
||||||
|
"kate\n\n/home/user/proj\ntext\naddr=42\n8\nmain.cpp"));
|
||||||
|
|
||||||
|
PlumbMsg back;
|
||||||
|
QVERIFY(PlumbMsg::unpack(packed, &back));
|
||||||
|
QCOMPARE(back.src, m.src);
|
||||||
|
QCOMPARE(back.dst, m.dst);
|
||||||
|
QCOMPARE(back.wdir, m.wdir);
|
||||||
|
QCOMPARE(back.type, m.type);
|
||||||
|
QCOMPARE(back.lookup(QStringLiteral("addr")), QStringLiteral("42"));
|
||||||
|
QCOMPARE(back.data, m.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestPlumbMsg::attrQuotingRoundTrip()
|
||||||
|
{
|
||||||
|
// A value with a space must be single-quoted on the wire.
|
||||||
|
QList<PlumbAttr> attr{{QStringLiteral("click"), QStringLiteral("1 2")}};
|
||||||
|
const QByteArray packed = packPlumbAttr(attr);
|
||||||
|
QCOMPARE(packed, QByteArrayLiteral("click='1 2'"));
|
||||||
|
const QList<PlumbAttr> back = unpackPlumbAttr(packed);
|
||||||
|
QCOMPARE(back, attr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestPlumbMsg::attrDoubledQuote()
|
||||||
|
{
|
||||||
|
// A literal ' is doubled inside quotes.
|
||||||
|
QList<PlumbAttr> attr{{QStringLiteral("x"), QStringLiteral("a'b c")}};
|
||||||
|
const QByteArray packed = packPlumbAttr(attr);
|
||||||
|
QCOMPARE(packed, QByteArrayLiteral("x='a''b c'"));
|
||||||
|
const QList<PlumbAttr> back = unpackPlumbAttr(packed);
|
||||||
|
QCOMPARE(back.size(), 1);
|
||||||
|
QCOMPARE(back.first().value, QStringLiteral("a'b c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestPlumbMsg::shortNdataTolerated()
|
||||||
|
{
|
||||||
|
// libplumb clamps ndata to the actual trailing bytes; a wrong count still
|
||||||
|
// yields the real data (mirrors plumbunpackpartial behaviour).
|
||||||
|
const QByteArray buf = QByteArrayLiteral("s\nd\nw\ntext\n\n999\nhello");
|
||||||
|
PlumbMsg m;
|
||||||
|
QVERIFY(PlumbMsg::unpack(buf, &m));
|
||||||
|
QCOMPARE(QString::fromUtf8(m.data), QStringLiteral("hello"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QTEST_MAIN(TestPlumbMsg)
|
||||||
|
#include "test_plumbmsg.moc"
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||||
|
*/
|
||||||
|
#include "plumbresolve.h"
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QTest>
|
||||||
|
|
||||||
|
using namespace katecustom;
|
||||||
|
|
||||||
|
class TestPlumbResolve : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
private Q_SLOTS:
|
||||||
|
void emptyIsInvalid();
|
||||||
|
void lineOnly();
|
||||||
|
void lineColon();
|
||||||
|
void lineDot();
|
||||||
|
void regexIsInvalid();
|
||||||
|
void zeroIsInvalid();
|
||||||
|
};
|
||||||
|
|
||||||
|
void TestPlumbResolve::emptyIsInvalid()
|
||||||
|
{
|
||||||
|
const PlumbAddr a = parsePlumbAddr(QString());
|
||||||
|
QVERIFY(!a.valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestPlumbResolve::lineOnly()
|
||||||
|
{
|
||||||
|
// "2" on the wire (1-based) -> line index 1, column 0.
|
||||||
|
const PlumbAddr a = parsePlumbAddr(QStringLiteral("2"));
|
||||||
|
QVERIFY(a.valid);
|
||||||
|
QCOMPARE(a.line, 1);
|
||||||
|
QCOMPARE(a.column, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestPlumbResolve::lineColon()
|
||||||
|
{
|
||||||
|
// "12:3" -> line 11, column 2.
|
||||||
|
const PlumbAddr a = parsePlumbAddr(QStringLiteral("12:3"));
|
||||||
|
QVERIFY(a.valid);
|
||||||
|
QCOMPARE(a.line, 11);
|
||||||
|
QCOMPARE(a.column, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestPlumbResolve::lineDot()
|
||||||
|
{
|
||||||
|
// "12.3" -> line 11, column 2 (acme also emits the dot form).
|
||||||
|
const PlumbAddr a = parsePlumbAddr(QStringLiteral("12.3"));
|
||||||
|
QVERIFY(a.valid);
|
||||||
|
QCOMPARE(a.line, 11);
|
||||||
|
QCOMPARE(a.column, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestPlumbResolve::regexIsInvalid()
|
||||||
|
{
|
||||||
|
// A regex address has no leading digits -> we can't position; leave invalid.
|
||||||
|
const PlumbAddr a = parsePlumbAddr(QStringLiteral("/func foo/"));
|
||||||
|
QVERIFY(!a.valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestPlumbResolve::zeroIsInvalid()
|
||||||
|
{
|
||||||
|
// Line 0 is not a valid 1-based address.
|
||||||
|
const PlumbAddr a = parsePlumbAddr(QStringLiteral("0"));
|
||||||
|
QVERIFY(!a.valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
QTEST_MAIN(TestPlumbResolve)
|
||||||
|
#include "test_plumbresolve.moc"
|
||||||
Loading…
Reference in New Issue