@gjsify/devtools 0.27.1 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -2
- package/lib/esm/devtools-service.js +1 -1
- package/lib/esm/index.js +1 -1
- package/lib/esm/install.js +1 -1
- package/lib/esm/peer-transport.js +1 -0
- package/lib/types/devtools-service.d.ts +33 -4
- package/lib/types/extension.d.ts +9 -0
- package/lib/types/index.d.ts +4 -1
- package/lib/types/install.d.ts +61 -0
- package/lib/types/peer-transport.d.ts +57 -0
- package/lib/types/peer-transport.spec.d.ts +2 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -43,6 +43,66 @@ gdbus call --session -d org.example.App -o /org/example/App/devtools \
|
|
|
43
43
|
-m org.gjsify.Devtools.Screenshot 'window' # PNG bytes as a GVariant `ay`
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
## No session bus? (macOS, Windows)
|
|
47
|
+
|
|
48
|
+
**The capture is already OS-agnostic — only the transport ever needed a bus.** `captureWidgetPng` is pure GTK4/GSK, in-process (`widget.get_native().get_renderer()` → `Gtk.WidgetPaintable` → `Gsk.Renderer.render_texture` → `texture.save_to_png_bytes()`): no `grim`, no `gnome-screenshot`, no xdg portal, no GStreamer, no OS branch. It is CI-proven on darwin-arm64.
|
|
49
|
+
|
|
50
|
+
What macOS and Windows lack is the **session bus**. So `installDevtools` speaks GDBus **peer-to-peer** when there is none: it stands up a `Gio.DBusServer` on a socket and exports the same `org.gjsify.Devtools` interface — every method, same object path — on each incoming connection.
|
|
51
|
+
|
|
52
|
+
### The recipe (one env var, no daemon)
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# the app
|
|
56
|
+
GJSIFY_DEVTOOLS=1 GJSIFY_DEVTOOLS_ADDRESS=unix:path=/tmp/myapp-devtools.sock gjs -m dist/app.js
|
|
57
|
+
# the bridge
|
|
58
|
+
GJSIFY_DEVTOOLS_ADDRESS=unix:path=/tmp/myapp-devtools.sock gjsify debug --bus-name org.example.App
|
|
59
|
+
# …or equivalently: gjsify debug --bus-name org.example.App --address unix:path=/tmp/myapp-devtools.sock
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Setting nothing at all also works: with no usable bus the app picks a socket itself, logs the address and **publishes** it to `<runtime-dir>/gjsify-devtools/<app-id>[.<instance>].address` (removed on `unexport` and on `shutdown`) — the bus-less analogue of `DBUS_SESSION_BUS_ADDRESS`. The bridge reads that file, so `gjsify debug` needs no configuration either, and it **verifies** it: an address nothing answers on is deleted and the bridge continues down its precedence, because Ctrl-C, `SIGKILL` and a crash all skip every cleanup hook an app has.
|
|
63
|
+
|
|
64
|
+
### Restarting, and two apps at once
|
|
65
|
+
|
|
66
|
+
`unix:path=` is a pinned address, so the two obvious mishaps have defined answers instead of a `Gio.IOErrorEnum`:
|
|
67
|
+
|
|
68
|
+
- **Ctrl-C, then restart.** `stop()` unlinks the socket, an abnormal exit does not, and binding over the leftover inode fails with `ADDRESS_IN_USE` exactly like a live server would. The app therefore probes it: a socket that refuses connections is **reclaimed** (and the reclaim is logged), a live one is never touched, and a path holding something that is not a socket is never deleted at all.
|
|
69
|
+
- **A second app on the same address.** The first keeps its control plane; the second logs `devtools stayed OFF, the app is unaffected — cannot listen on …` and **runs normally without devtools**. `installDevtools` never throws: it is called from `startup`/`activate` handlers, and GJS logs a throw from a handler and swallows it — so a throw would silently cost the app its `present()` call, not just its devtools.
|
|
70
|
+
|
|
71
|
+
### Transport precedence
|
|
72
|
+
|
|
73
|
+
| explicit address (`options.address` / `GJSIFY_DEVTOOLS_ADDRESS`) | app holds a bus connection | session bus answers | transport |
|
|
74
|
+
|---|---|---|---|
|
|
75
|
+
| set | any | any | peer server at that address |
|
|
76
|
+
| unset | yes | any | the session bus — **Linux is unchanged** |
|
|
77
|
+
| unset | no | no | peer server at an auto-picked socket (address logged + published) |
|
|
78
|
+
| unset | no | yes | none: the bus works, so the diagnosis is a too-early call — call `installDevtools` from `startup` |
|
|
79
|
+
|
|
80
|
+
Auto-picking rather than failing is deliberate: `GJSIFY_DEVTOOLS=1` asks for a control plane, and answering it with a service that is constructed but never exported is exactly the silent absence this transport removes. The bridge mirrors the order: `--address` → env var → published address file → session bus.
|
|
81
|
+
|
|
82
|
+
### The `dbus-run-session` trap
|
|
83
|
+
|
|
84
|
+
The fallback everyone reaches for first — an external session bus — **does not work on macOS as spelled everywhere else**. Homebrew's dbus listens on launchd, so `dbus-run-session` dies with `DBUS_LAUNCHD_SESSION_BUS_SOCKET is empty`. A hand-started private bus does work:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
dbus-daemon --session --nofork --address=unix:path=/tmp/devtools-bus.sock &
|
|
88
|
+
export DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/devtools-bus.sock
|
|
89
|
+
# …or hand dbus-run-session a config file whose <listen> is a plain socket:
|
|
90
|
+
dbus-run-session --config-file=session.conf -- <command> # <listen>unix:tmpdir=/tmp</listen>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Prefer the peer transport; the above is only for tooling that must have a bus NAME.
|
|
94
|
+
|
|
95
|
+
### Two macOS gotchas that are not about devtools
|
|
96
|
+
|
|
97
|
+
- **`DYLD_*` must be exported by the shell that `exec`s `gjs`.** SIP strips those variables through a `nohup`/`sh` wrapper, so a prebuild's sibling library silently fails to load.
|
|
98
|
+
- **Cold start can take a very long time.** Measured on a GPU-less QEMU VM: 94 s before the control plane answered its first call (1 s warm). Anything that waits on the app must **poll `GetStatus`**, never sleep a fixed time.
|
|
99
|
+
|
|
100
|
+
### Address forms
|
|
101
|
+
|
|
102
|
+
`unix:path=…` / `unix:tmpdir=…` (POSIX; `unix:tmpdir` is the auto-picked default — GDBus generates a unique socket name, so there is no stale-socket collision) and `nonce-tcp:host=127.0.0.1` (the portable form; GLib implements no unix-socket transport on win32). Both measured working under gjs 1.88.1.
|
|
103
|
+
|
|
104
|
+
Access control follows the address FAMILY, not the platform: a unix socket authenticates with `EXTERNAL` and the server requires the peer's uid to equal ours; TCP carries no peer credentials — that same flag rejects every client there — so the nonce file is the secret. On a shared machine that is measured, not asserted: GDBus creates the nonce file **mode 0600**, binds **127.0.0.1 only**, and a client that opens the port without sending the 16 nonce bytes gets no reply at all (the connection times out). The 0600 is spec-checked, so a GLib change or a wrong flag surfaces as a failing test rather than as an open control plane.
|
|
105
|
+
|
|
46
106
|
## Generic methods (out of the box)
|
|
47
107
|
|
|
48
108
|
`GetStatus`, `Screenshot` (GSK widget snapshot PNG), `ListActions` / `ActivateAction` / `ChangeActionState` (GAction bridge), `PresentWindow`, `ResizeWindow`, plus full introspection: `ListToplevels`, `DumpTree`, `GetProperty` (by index path), `GetFocused`, `DumpGSettings`, `DumpCss` / `SwapCss` (live CSS hot-swap).
|
|
@@ -68,9 +128,10 @@ installDevtools(this, { extend: [myExtension] });
|
|
|
68
128
|
|
|
69
129
|
## Exports
|
|
70
130
|
|
|
71
|
-
- `installDevtools(app, options)` → returns a `DevtoolsService
|
|
72
|
-
- `DevtoolsService` — the exported service object `installDevtools` returns and `uninstallDevtools` accepts.
|
|
131
|
+
- `installDevtools(app, options)` → returns a `DevtoolsService`, or `null` when the env gate is off **or the transport could not come up**. It never throws: every failure is one stderr line naming the address and the way out. `uninstallDevtools(service)` — opt-in lifecycle; it stops the socket *and* retracts the published address.
|
|
132
|
+
- `DevtoolsService` — the exported service object `installDevtools` returns and `uninstallDevtools` accepts; `service.peerAddress` is the address clients dial in bus-less mode (`null` on a bus), `service.addressFile` is where that address is published (`null` when it is not).
|
|
73
133
|
- `DevtoolsExtension`, `InstallDevtoolsOptions` — the extension + options types.
|
|
134
|
+
- `startDevtoolsPeerServer(service, objectPath, address?)` → `DevtoolsPeerServer` — the bus-less transport by hand; it throws `DevtoolsPeerServerError` (with `address` + `reason`) when the address is unusable, which is what `installDevtools` catches. `chooseDevtoolsTransport(input)` — the precedence table above as a pure function; `writeDevtoolsAddressFile` / `removeDevtoolsAddressFile` — publish/retract a peer address.
|
|
74
135
|
- `captureWidgetPng` (GSK screenshot), `buildVariant` / `variantKindFor` / `VariantKind` (GVariant), `activateAction` / `changeActionState` / `describeActions` (GAction bridge).
|
|
75
136
|
- widget-tree helpers (`dumpTree`, `getWidgetProperty`, `listToplevels`, `resolveWidgetPath`, …), `dumpCss` / `swapCss` / `removeCss`, `dumpGSettings`, `buildDevtoolsIfaceXml`.
|
|
76
137
|
- the re-exported `@gjsify/devtools-protocol` contract.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import"./_virtual/_rolldown/runtime.js";import{activateAction as e,changeActionState as t,describeActions as n}from"./actions.js";import{dumpCss as r,swapCss as i}from"./css.js";import{buildDevtoolsIfaceXml as a}from"./devtools-iface.js";import{
|
|
1
|
+
import"./_virtual/_rolldown/runtime.js";import{activateAction as e,changeActionState as t,describeActions as n}from"./actions.js";import{dumpCss as r,swapCss as i}from"./css.js";import{buildDevtoolsIfaceXml as a}from"./devtools-iface.js";import{removeDevtoolsAddressFile as o}from"./peer-transport.js";import{captureWidgetPng as s}from"./screenshot.js";import{dumpGSettings as c}from"./gsettings.js";import{activateWidget as l,dumpTree as u,getWidgetProperty as d,listToplevels as f,pathOfWidget as p,resolveWidgetPath as m,widgetType as h}from"./widget-tree.js";import g from"@girs/glib-2.0";import _ from"@girs/gtk-4.0";import{GENERIC_METHODS as v,formatDbusErrorMessage as y}from"@gjsify/devtools-protocol";import b from"@girs/gio-2.0";function frameDelay(e){return new Promise(t=>{g.timeout_add(g.PRIORITY_DEFAULT,e,()=>(t(),g.SOURCE_REMOVE))})}async function captureWidgetWhenRenderable(e,t=12,n=50){for(let r=0;r<t;r++){let t=s(e);if(t)return t;await frameDelay(n)}return s(e)}var DevtoolsService=class{constructor(e,t){this._app=e,this._options=t,this._exported=new Map,this._peer=null,this._addressFile=null,this._kinds=new Map,this._extensions=t.extend??[];for(let[e,t]of Object.entries(v))this._kinds.set(e,t);for(let e of this._extensions){for(let[t,n]of Object.entries(e.methodKinds))this._kinds.set(t,n);for(let[t,n]of Object.entries(e.handlers)){let e=n;this[t]=(...n)=>(this._guard(t),e(...n))}}}export(e,t){if(this._exported.has(e))return;let n=a(this._extensions.flatMap(e=>e.methodsXml??[])),r=b.DBusExportedObject.wrapJSObject(n,this);r.export(e,t),this._exported.set(e,r)}unexport(e){if(e){this._exported.get(e)?.unexport(),this._exported.delete(e);return}for(let e of this._exported.values())e.unexport();this._exported.clear(),this._peer?.stop(),this._peer=null,this._addressFile&&=(o(this._addressFile),null)}attachPeerServer(e,t){this._peer=e,this._addressFile=t??null}get peerAddress(){return this._peer?.address??null}get addressFile(){return this._addressFile}GetStatus(){let e=this._app.get_active_window(),t={appId:this._app.get_application_id()??`unknown`,instance:this._options.instance??`default`,activeWindow:e?{id:e.get_name()||`window`,title:e.get_title()??``,mapped:e.get_mapped()}:null,toplevelCount:_.Window.get_toplevels().get_n_items(),focusedWidget:null,paused:this._isPaused()};for(let e of this._extensions){let n=e.contributeStatus?.();n&&Object.assign(t,n)}return JSON.stringify(t)}async ScreenshotAsync(e,t){try{let e=await this._captureActiveWindowPng();t.return_value(new g.Variant(`(ay)`,[e]))}catch(e){t.return_dbus_error(`org.gjsify.Devtools.Error.Screenshot`,e instanceof Error?e.message:String(e))}}async _captureActiveWindowPng(){let e=this._app.get_active_window();if(!e)return new Uint8Array;let t=s(e);return t||=(e.present(),await captureWidgetWhenRenderable(e)),t??new Uint8Array}ListActions(){let e={app:n(this._actionGroup(`app`)),win:n(this._actionGroup(`win`))};return JSON.stringify(e)}ActivateAction(t,n,r){this._guard(`ActivateAction`);let i=this._scope(t);e(this._requireGroup(i,n),n,this._parseValue(r))}ChangeActionState(e,n,r){this._guard(`ChangeActionState`);let i=this._scope(e);t(this._requireGroup(i,n),n,this._parseValue(r))}PresentWindow(){this._app.get_active_window()?.present()}ResizeWindow(e,t){this._guard(`ResizeWindow`);let n=this._app.get_active_window();if(!n)throw Error(y(`unavailable`,`no active window to resize`));let r=Math.max(1,Math.round(e)),i=Math.max(1,Math.round(t));return n.is_maximized()&&n.unmaximize(),n.is_fullscreen()&&n.unfullscreen(),n.set_default_size(r,i),[r,i]}ListToplevels(){return JSON.stringify(f())}DumpTree(e,t){let n=this._resolveRootWidget(e);if(!n)throw Error(y(`not-found`,`no widget at '${e||`active window`}'`));return JSON.stringify(u(n.widget,t>0?t:8,n.path))}GetProperty(e,t){let n=this._resolveRootWidget(e);if(!n)throw Error(y(`not-found`,`no widget at '${e}'`));let r=d(n.widget,t);if(r===void 0)throw Error(y(`not-found`,`widget has no property '${t}'`));return JSON.stringify(r)}GetFocused(){let e=this._app.get_active_window(),t=e?e.get_focus():null;return JSON.stringify(t?{path:p(t),name:t.get_name()||null,type:h(t)}:null)}ActivateWidget(e){this._guard(`ActivateWidget`);let t=this._resolveRootWidget(e);if(!t)throw Error(y(`not-found`,`no widget at '${e}'`));return l(t.widget)}DumpGSettings(e){return JSON.stringify(c(e))}DumpCss(){return JSON.stringify(r())}SwapCss(e,t){return this._guard(`SwapCss`),i(e,t)}_resolveRootWidget(e){if(!e||e===`window`||e===`active`){let e=this._app.get_active_window();return e?{widget:e,path:p(e)??`toplevel:0`}:null}let t=m(e);return t?{widget:t,path:e}:null}_isPaused(){return this._options.paused?.()??!1}_guard(e){let t=this._kinds.get(e);if(!t)throw Error(y(`internal`,`unclassified devtools method '${e}'`));if(t===`mutating`&&this._isPaused())throw Error(y(`paused`,`${e} rejected — external control is paused. Read-only methods keep working.`))}_scope(e){return e===`app`?`app`:`win`}_actionGroup(e){if(e===`app`)return this._app;if(this._options.winActionGroup)return this._options.winActionGroup;let t=this._app.get_active_window();return t instanceof _.ApplicationWindow?t:null}_requireGroup(e,t){let n=this._actionGroup(e);if(!n)throw Error(y(`unavailable`,`no '${e}' action group`));if(!n.has_action(t))throw Error(y(`not-found`,`unknown action ${e}.${t}`));return n}_parseValue(e){if(!e||e===`null`)return null;try{return JSON.parse(e)}catch{throw Error(y(`invalid-params`,`value_json is not valid JSON: ${e}`))}}};export{DevtoolsService};
|
package/lib/esm/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{buildVariant as e,variantKindFor as t}from"./gvariant.js";import{activateAction as n,changeActionState as r,describeActions as i}from"./actions.js";import{dumpCss as a,removeCss as o,swapCss as s}from"./css.js";import{buildDevtoolsIfaceXml as c}from"./devtools-iface.js";import{
|
|
1
|
+
import{buildVariant as e,variantKindFor as t}from"./gvariant.js";import{activateAction as n,changeActionState as r,describeActions as i}from"./actions.js";import{dumpCss as a,removeCss as o,swapCss as s}from"./css.js";import{buildDevtoolsIfaceXml as c}from"./devtools-iface.js";import{DevtoolsPeerServerError as l,removeDevtoolsAddressFile as u,startDevtoolsPeerServer as d,writeDevtoolsAddressFile as f}from"./peer-transport.js";import{captureWidgetPng as p}from"./screenshot.js";import{dumpGSettings as m}from"./gsettings.js";import{buildWidgetPath as h,dumpTree as g,getWidgetProperty as _,listToplevels as v,parseWidgetPath as y,pathOfWidget as b,resolveWidgetPath as x,widgetType as S}from"./widget-tree.js";import{DevtoolsService as C}from"./devtools-service.js";import{chooseDevtoolsTransport as w,installDevtools as T,uninstallDevtools as E}from"./install.js";export*from"@gjsify/devtools-protocol";export{l as DevtoolsPeerServerError,C as DevtoolsService,n as activateAction,c as buildDevtoolsIfaceXml,e as buildVariant,h as buildWidgetPath,p as captureWidgetPng,r as changeActionState,w as chooseDevtoolsTransport,i as describeActions,a as dumpCss,m as dumpGSettings,g as dumpTree,_ as getWidgetProperty,T as installDevtools,v as listToplevels,y as parseWidgetPath,b as pathOfWidget,o as removeCss,u as removeDevtoolsAddressFile,x as resolveWidgetPath,d as startDevtoolsPeerServer,s as swapCss,E as uninstallDevtools,t as variantKindFor,S as widgetType,f as writeDevtoolsAddressFile};
|
package/lib/esm/install.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import"./_virtual/_rolldown/runtime.js";import{
|
|
1
|
+
import"./_virtual/_rolldown/runtime.js";import{DevtoolsPeerServerError as e,removeDevtoolsAddressFile as t,startDevtoolsPeerServer as n,writeDevtoolsAddressFile as r}from"./peer-transport.js";import{DevtoolsService as i}from"./devtools-service.js";import a from"@girs/glib-2.0";import{DEVTOOLS_ADDRESS_ENV as o,DEVTOOLS_ENABLE_ENV as s,DEVTOOLS_INSTANCE_ENV as c,DEVTOOLS_INTERFACE as l,devtoolsAddressFilePath as u,isDevtoolsEnabledValue as d}from"@gjsify/devtools-protocol";import f from"@girs/gio-2.0";const p=`DBUS_SESSION_BUS_ADDRESS`,m=new Set;function envEnabled(){return d(a.getenv(s))}function envValue(e){let t=a.getenv(e);return t&&t!==``?t:null}function sessionBusUsable(){if(!envValue(p))return!1;try{return f.bus_get_sync(f.BusType.SESSION,null),!0}catch{return!1}}function chooseDevtoolsTransport(e){return e.requestedAddress?{kind:`peer`,address:e.requestedAddress,reason:`requested`}:e.hasAppConnection?{kind:`session-bus`}:e.sessionBusUsable?{kind:`unregistered`}:{kind:`peer`,address:null,reason:`no-session-bus`}}function describeMissingConnection(e,t){return e?t?`[gjsify-devtools] the session bus answers but this application is not registered on it yet — call installDevtools() from the GtkApplication "startup" handler (the bus connection and object path exist only after registration).`:`[gjsify-devtools] ${p} is set (${e}) but no bus answers there — using the bus-less peer transport instead. On macOS this is the usual state: Homebrew's dbus listens on launchd, so the advertised address only works while that socket is live.`:`[gjsify-devtools] this machine has no session bus (${p} is unset) — using the bus-less peer transport instead. Point the bridge at the address below, or set ${o} on both the app and \`gjsify debug\` to pin it yourself.`}function installDevtools(e,t={}){if(!(t.enabled??envEnabled()))return null;let n=t.instance??envValue(c)??void 0,r=new i(e,{...t,instance:n});try{return exportDevtools(e,r,t,n)}catch(e){return r.unexport(),console.error(`[gjsify-devtools] devtools stayed OFF, the app is unaffected — ${describeInstallFailure(e)}`),null}}function describeInstallFailure(t){return t instanceof e||t instanceof Error?t.message:String(t)}function exportDevtools(e,i,s,c){let d=e.get_is_registered(),f=d?e.get_dbus_connection():null,h=d?e.get_dbus_object_path():null,g=envValue(p),_=!!(f&&h),v=_||sessionBusUsable(),y=chooseDevtoolsTransport({requestedAddress:s.address??envValue(o),hasAppConnection:_,sessionBusUsable:v}),b=e.get_application_id()??`unknown`,x=`${h??`/${b.replace(/\./g,`/`)}`}/devtools`;if((y.kind===`unregistered`||y.kind===`peer`&&y.reason===`no-session-bus`)&&console.error(describeMissingConnection(g,v)),y.kind===`session-bus`&&f)i.export(f,x),console.log(`[gjsify-devtools] exported ${l} at ${x} (dest ${b})`);else if(y.kind===`peer`){let o=n(i,x,y.address??void 0),s=r(u(a.get_user_runtime_dir(),b,c),o.address);i.attachPeerServer(o,s),console.log(`[gjsify-devtools] exported ${l} at ${x} (peer address ${o.address}${s?`, published to ${s}`:``})`),s&&e.connect(`shutdown`,()=>t(s))}return m.add(i),i}function uninstallDevtools(e){e.unexport(),m.delete(e)}export{chooseDevtoolsTransport,describeMissingConnection,installDevtools,uninstallDevtools};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import"./_virtual/_rolldown/runtime.js";import e from"@girs/glib-2.0";import{DEVTOOLS_ADDRESS_ENV as t}from"@gjsify/devtools-protocol";import n from"@girs/gio-2.0";function isWin32(){return e.DIR_SEPARATOR===92}function defaultListenAddress(){return isWin32()?`nonce-tcp:host=127.0.0.1`:`unix:tmpdir=${e.get_tmp_dir()}`}var DevtoolsPeerServerError=class extends Error{constructor(e,t,n,r){super(e),this.address=t,this.reason=n,this.name=`DevtoolsPeerServerError`,this.cause=r}};function isAddressInUse(t){return t instanceof e.Error&&t.matches(n.io_error_quark(),n.IOErrorEnum.ADDRESS_IN_USE)}function unixSocketPath(e){if(!e.startsWith(`unix:`))return null;for(let t of e.slice(5).split(`,`)){let e=t.indexOf(`=`);if(!(e<0||t.slice(0,e)!==`path`))try{return decodeURIComponent(t.slice(e+1))}catch{return null}}return null}function inspectExistingSocket(e){try{if((n.File.new_for_path(e).query_info(`unix::mode`,n.FileQueryInfoFlags.NONE,null).get_attribute_uint32(`unix::mode`)&61440)!=49152)return`not-a-socket`}catch{return`not-a-socket`}let t=new n.SocketClient;t.set_timeout(2);let r;try{r=t.connect(n.UnixSocketAddress.new(e),null)}catch{return`dead`}return r.close(null),`live`}function clearReclaimableSocket(r){let i=unixSocketPath(r);if(!i||!e.file_test(i,e.FileTest.EXISTS))return;let a=inspectExistingSocket(i);if(a===`live`)throw new DevtoolsPeerServerError(`cannot listen on ${r}: another process is already listening there. Stop it, or point ${t} (or options.address) at a different socket — GJSIFY_DEVTOOLS_INSTANCE=<label> lets two devtools-enabled apps run side by side.`,r,`address-in-use`);if(a===`not-a-socket`)throw new DevtoolsPeerServerError(`cannot listen on ${r}: ${i} already exists and is not a socket, so it will NOT be removed. Point ${t} (or options.address) at a free path.`,r,`occupied-by-other-file`);try{n.File.new_for_path(i).delete(null)}catch(e){throw new DevtoolsPeerServerError(`cannot listen on ${r}: ${i} is a socket nothing listens on, but removing it failed (${e}). Delete it by hand, or point ${t} at a free path.`,r,`address-in-use`,e)}console.log(`[gjsify-devtools] reclaimed the stale socket ${i}: nothing was listening on it (an abnormal exit — Ctrl-C, SIGKILL, a crash — leaves the inode behind).`)}function flagsForAddress(e){return e.startsWith(`tcp:`)||e.startsWith(`nonce-tcp:`)?n.DBusServerFlags.NONE:n.DBusServerFlags.AUTHENTICATION_REQUIRE_SAME_USER}function startDevtoolsPeerServer(e,r,i){let a=i&&i!==``?i:defaultListenAddress();clearReclaimableSocket(a);let o;try{o=n.DBusServer.new_sync(a,flagsForAddress(a),n.dbus_generate_guid(),null,null)}catch(e){let n=isAddressInUse(e);throw new DevtoolsPeerServerError(n?`cannot listen on ${a}: the address is already in use (${e}). Stop whatever holds it, or point ${t} (or options.address) at a different address.`:`cannot listen on ${a}: ${e}. Check the address form — \`unix:path=/tmp/app.sock\`, \`unix:tmpdir=/tmp\` or \`nonce-tcp:host=127.0.0.1\`.`,a,n?`address-in-use`:`listen-failed`,e)}let s=new Set;o.connect(`new-connection`,(t,n)=>(s.add(n),n.connect(`closed`,()=>{e.unexport(n),s.delete(n)}),e.export(n,r),!0)),o.start();let c=!1;return{address:o.get_client_address(),objectPath:r,get connectionCount(){return s.size},stop(){if(!c){c=!0,o.stop();for(let t of s)e.unexport(t),!t.is_closed()&&t.close(null,(e,t)=>{try{e.close_finish(t)}catch{}});s.clear()}}}}function writeDevtoolsAddressFile(t,r){try{if(e.mkdir_with_parents(e.path_get_dirname(t),448),!e.file_set_contents(t,`${r}\n`))return null}catch(e){return console.error(`[gjsify-devtools] could not publish the peer address to ${t}: ${e}`),null}try{n.File.new_for_path(t).set_attribute_uint32(`unix::mode`,384,n.FileQueryInfoFlags.NONE,null)}catch(e){console.error(`[gjsify-devtools] could not restrict ${t} to the current user: ${e}`)}return t}function removeDevtoolsAddressFile(e){try{n.File.new_for_path(e).delete(null)}catch{}}export{DevtoolsPeerServerError,removeDevtoolsAddressFile,startDevtoolsPeerServer,writeDevtoolsAddressFile};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Gio from '@girs/gio-2.0';
|
|
2
2
|
import Gtk from '@girs/gtk-4.0';
|
|
3
3
|
import type { InstallDevtoolsOptions } from './extension.js';
|
|
4
|
+
import { type DevtoolsPeerServer } from './peer-transport.js';
|
|
4
5
|
/**
|
|
5
6
|
* The permanent `org.gjsify.Devtools` DBus interface for a GTK app — lets
|
|
6
7
|
* external tooling (the MCP bridge, `gdbus`, scripts) inspect, screenshot,
|
|
@@ -12,14 +13,42 @@ import type { InstallDevtoolsOptions } from './extension.js';
|
|
|
12
13
|
export declare class DevtoolsService {
|
|
13
14
|
private readonly _app;
|
|
14
15
|
private readonly _options;
|
|
15
|
-
|
|
16
|
+
/**
|
|
17
|
+
* One `Gio.DBusExportedObject` PER CONNECTION. The session bus is a single
|
|
18
|
+
* shared pipe, but the peer transport (`peer-transport.ts`) hands out one
|
|
19
|
+
* `Gio.DBusConnection` per client with no shared object registry — a
|
|
20
|
+
* single-slot field would export the interface to the FIRST peer only, and
|
|
21
|
+
* every later client would see `UnknownMethod` for all 26 methods. Keyed by
|
|
22
|
+
* connection, so `export()` stays idempotent PER connection while serving
|
|
23
|
+
* any number of them.
|
|
24
|
+
*/
|
|
25
|
+
private readonly _exported;
|
|
26
|
+
private _peer;
|
|
27
|
+
private _addressFile;
|
|
16
28
|
private readonly _extensions;
|
|
17
29
|
private readonly _kinds;
|
|
18
30
|
constructor(_app: Gtk.Application, _options: InstallDevtoolsOptions);
|
|
19
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Export the interface at `objectPath` on `connection`. Idempotent per
|
|
33
|
+
* connection; accepts ANY `Gio.DBusConnection` — the app's session-bus
|
|
34
|
+
* connection or a peer connection from a `Gio.DBusServer`.
|
|
35
|
+
*/
|
|
20
36
|
export(connection: Gio.DBusConnection, objectPath: string): void;
|
|
21
|
-
/**
|
|
22
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Tear the interface down — on `connection` only, or (default) everywhere,
|
|
39
|
+
* including a peer server attached via {@link attachPeerServer}. Idempotent.
|
|
40
|
+
*/
|
|
41
|
+
unexport(connection?: Gio.DBusConnection): void;
|
|
42
|
+
/**
|
|
43
|
+
* Record the peer server hosting this service — plus the file its address was
|
|
44
|
+
* published to, if any — so `unexport()` closes the socket AND retracts the
|
|
45
|
+
* claim, and callers can read both back rather than scraping the app's stderr.
|
|
46
|
+
*/
|
|
47
|
+
attachPeerServer(peer: DevtoolsPeerServer, addressFile?: string | null): void;
|
|
48
|
+
/** The peer address clients must dial, or `null` when this service is on a bus. */
|
|
49
|
+
get peerAddress(): string | null;
|
|
50
|
+
/** Where this service's peer address is published, or `null` when it is not. */
|
|
51
|
+
get addressFile(): string | null;
|
|
23
52
|
/** `GetStatus() -> s` — JSON snapshot of the app's live state. */
|
|
24
53
|
GetStatus(): string;
|
|
25
54
|
/**
|
package/lib/types/extension.d.ts
CHANGED
|
@@ -23,6 +23,15 @@ export interface InstallDevtoolsOptions {
|
|
|
23
23
|
enabled?: boolean;
|
|
24
24
|
/** Per-instance label (multi-instance side-by-side). Default: the `GJSIFY_DEVTOOLS_INSTANCE` env var. */
|
|
25
25
|
instance?: string;
|
|
26
|
+
/**
|
|
27
|
+
* D-Bus address to LISTEN on for the bus-less peer-to-peer transport
|
|
28
|
+
* (`unix:tmpdir=/tmp`, `unix:path=/tmp/app.sock`, `nonce-tcp:host=127.0.0.1`).
|
|
29
|
+
* Takes precedence over the session bus. Default: the
|
|
30
|
+
* `GJSIFY_DEVTOOLS_ADDRESS` env var; with neither, the session bus is used
|
|
31
|
+
* when it answers and a socket is auto-picked when it does not — the full
|
|
32
|
+
* precedence table lives on `chooseDevtoolsTransport`.
|
|
33
|
+
*/
|
|
34
|
+
address?: string;
|
|
26
35
|
/** App-specific extensions. */
|
|
27
36
|
extend?: readonly DevtoolsExtension[];
|
|
28
37
|
/**
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
export { installDevtools, uninstallDevtools } from './install.js';
|
|
1
|
+
export { chooseDevtoolsTransport, installDevtools, uninstallDevtools } from './install.js';
|
|
2
|
+
export type { DevtoolsTransportChoice } from './install.js';
|
|
2
3
|
export { DevtoolsService } from './devtools-service.js';
|
|
3
4
|
export type { DevtoolsExtension, InstallDevtoolsOptions } from './extension.js';
|
|
5
|
+
export { DevtoolsPeerServerError, removeDevtoolsAddressFile, startDevtoolsPeerServer, writeDevtoolsAddressFile, } from './peer-transport.js';
|
|
6
|
+
export type { DevtoolsPeerServer } from './peer-transport.js';
|
|
4
7
|
export { captureWidgetPng } from './screenshot.js';
|
|
5
8
|
export { buildVariant, variantKindFor } from './gvariant.js';
|
|
6
9
|
export type { VariantKind } from './gvariant.js';
|
package/lib/types/install.d.ts
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
import type Gtk from '@girs/gtk-4.0';
|
|
2
2
|
import { DevtoolsService } from './devtools-service.js';
|
|
3
3
|
import type { InstallDevtoolsOptions } from './extension.js';
|
|
4
|
+
/** Which transport `installDevtools` uses. See {@link chooseDevtoolsTransport}. */
|
|
5
|
+
export type DevtoolsTransportChoice =
|
|
6
|
+
/** Stand up a `Gio.DBusServer`. `address: null` = auto-pick a socket. */
|
|
7
|
+
{
|
|
8
|
+
kind: 'peer';
|
|
9
|
+
address: string | null;
|
|
10
|
+
reason: 'requested' | 'no-session-bus';
|
|
11
|
+
}
|
|
12
|
+
/** Export on the application's own session-bus connection. */
|
|
13
|
+
| {
|
|
14
|
+
kind: 'session-bus';
|
|
15
|
+
}
|
|
16
|
+
/** Nothing usable — the bus works, but this app has not registered on it yet. */
|
|
17
|
+
| {
|
|
18
|
+
kind: 'unregistered';
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The transport PRECEDENCE, as one pure function so it is machine-checked
|
|
22
|
+
* rather than merely described:
|
|
23
|
+
*
|
|
24
|
+
* | requested address | app has bus connection | session bus answers | choice |
|
|
25
|
+
* |---|---|---|---|
|
|
26
|
+
* | set | any | any | `peer` at that address — an explicit request always wins |
|
|
27
|
+
* | unset | yes | any | `session-bus` — **Linux is byte-unchanged** |
|
|
28
|
+
* | unset | no | no | `peer`, auto-picked — this machine has no usable bus |
|
|
29
|
+
* | unset | no | yes | `unregistered` — the bus is fine, the CALL SITE is early |
|
|
30
|
+
*
|
|
31
|
+
* The third row is the point of the whole transport, and it AUTO-PICKS rather
|
|
32
|
+
* than failing: `GJSIFY_DEVTOOLS=1` is an explicit request for a control plane,
|
|
33
|
+
* and answering it with "no control plane, one line on stderr" is exactly the
|
|
34
|
+
* silent absence being removed. It cannot regress Linux — a host whose bus
|
|
35
|
+
* answers never reaches that row.
|
|
36
|
+
*
|
|
37
|
+
* The fourth row deliberately does NOT fall back. The bus works, so the honest
|
|
38
|
+
* diagnosis is that `installDevtools` ran before the application registered
|
|
39
|
+
* (call it from `startup`); standing a socket up there would paper over a
|
|
40
|
+
* call-site bug and hide it behind a working-looking address.
|
|
41
|
+
*/
|
|
42
|
+
export declare function chooseDevtoolsTransport(input: {
|
|
43
|
+
requestedAddress?: string | null;
|
|
44
|
+
hasAppConnection: boolean;
|
|
45
|
+
sessionBusUsable: boolean;
|
|
46
|
+
}): DevtoolsTransportChoice;
|
|
47
|
+
/**
|
|
48
|
+
* The diagnostic for "no connection to export on". It must NAME THE RIGHT
|
|
49
|
+
* CAUSE: the old message blamed the call site unconditionally ("call
|
|
50
|
+
* installDevtools() from the startup handler"), which on macOS sent every reader
|
|
51
|
+
* hunting a bug in their own app when the machine simply has no session bus.
|
|
52
|
+
* Three distinguishable states, three answers.
|
|
53
|
+
*/
|
|
54
|
+
export declare function describeMissingConnection(sessionBusAddress: string | null, busAnswers: boolean): string;
|
|
4
55
|
/**
|
|
5
56
|
* Install the devtools control plane on a GTK application. A no-op returning
|
|
6
57
|
* `null` unless `GJSIFY_DEVTOOLS` is set (or `options.enabled` is true), so it
|
|
@@ -8,6 +59,16 @@ import type { InstallDevtoolsOptions } from './extension.js';
|
|
|
8
59
|
* handler — the session-bus connection + object path are only available after
|
|
9
60
|
* the application has registered.
|
|
10
61
|
*
|
|
62
|
+
* Needs NO session bus (macOS, Windows): with none, it listens on a socket of
|
|
63
|
+
* its own and publishes the address. {@link chooseDevtoolsTransport} documents
|
|
64
|
+
* the precedence; `options.address` / `GJSIFY_DEVTOOLS_ADDRESS` pins it.
|
|
65
|
+
*
|
|
66
|
+
* TOTAL BY CONTRACT: it never throws. Every failure — an address already in use,
|
|
67
|
+
* a stale socket that cannot be reclaimed, a malformed address — is reported on
|
|
68
|
+
* stderr with the address and the way out, and returns `null` like the disabled
|
|
69
|
+
* path. Callers wire it into `startup`/`activate` handlers whose remaining
|
|
70
|
+
* statements (`present()`, `onStartup()`) GJS would silently skip on a throw.
|
|
71
|
+
*
|
|
11
72
|
* @example
|
|
12
73
|
* ```ts
|
|
13
74
|
* this.connect('startup', () => {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { DevtoolsService } from './devtools-service.js';
|
|
2
|
+
/** A running peer-to-peer server hosting one `DevtoolsService`. */
|
|
3
|
+
export interface DevtoolsPeerServer {
|
|
4
|
+
/** The address a CLIENT must dial (concrete — `unix:tmpdir=` resolves to a real socket path here). */
|
|
5
|
+
readonly address: string;
|
|
6
|
+
/** The object path the interface is exported at on every peer connection. */
|
|
7
|
+
readonly objectPath: string;
|
|
8
|
+
/** How many peer connections are currently open. */
|
|
9
|
+
readonly connectionCount: number;
|
|
10
|
+
/** Stop listening and drop every peer connection. Idempotent. */
|
|
11
|
+
stop(): void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A peer transport that could not be stood up. `message` is already the
|
|
15
|
+
* operator-facing sentence — `installDevtools` logs it verbatim rather than
|
|
16
|
+
* paraphrasing a localised GIO error (on a German desktop the bare
|
|
17
|
+
* `Gio.DBusServer` failure reads `Fehler beim Binden an Adresse
|
|
18
|
+
* (GUnixSocketAddress): Die Adresse wird bereits verwendet`, which names neither
|
|
19
|
+
* the address nor a way out).
|
|
20
|
+
*/
|
|
21
|
+
export declare class DevtoolsPeerServerError extends Error {
|
|
22
|
+
readonly address: string;
|
|
23
|
+
readonly reason: 'address-in-use' | 'occupied-by-other-file' | 'listen-failed';
|
|
24
|
+
/**
|
|
25
|
+
* The GIO failure underneath, when there was one. A field rather than the
|
|
26
|
+
* `Error(message, { cause })` option because this package compiles to
|
|
27
|
+
* `lib: es2020`, which predates it.
|
|
28
|
+
*/
|
|
29
|
+
readonly cause?: unknown;
|
|
30
|
+
constructor(message: string, address: string, reason: 'address-in-use' | 'occupied-by-other-file' | 'listen-failed', cause?: unknown);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Listen on `address` (auto-picked when omitted) and export `service` at
|
|
34
|
+
* `objectPath` on every incoming peer connection.
|
|
35
|
+
*
|
|
36
|
+
* Each peer connection is its own `Gio.DBusConnection` with no shared name
|
|
37
|
+
* registry, so the interface has to be exported per connection — which is why
|
|
38
|
+
* `DevtoolsService.export()` tracks exports per connection rather than once. A
|
|
39
|
+
* second client would otherwise attach to a connection with nothing on it and
|
|
40
|
+
* see `UnknownMethod` for every call, the exact silent-absence failure this
|
|
41
|
+
* whole transport exists to remove.
|
|
42
|
+
*
|
|
43
|
+
* Throws a {@link DevtoolsPeerServerError} when the address cannot be listened on
|
|
44
|
+
* — an explicitly requested address that does not work is a configuration error
|
|
45
|
+
* the operator must see, and the message says what to do about it. `installDevtools`
|
|
46
|
+
* catches it: an opt-in diagnostic must never abort an app's lifecycle.
|
|
47
|
+
*/
|
|
48
|
+
export declare function startDevtoolsPeerServer(service: DevtoolsService, objectPath: string, address?: string): DevtoolsPeerServer;
|
|
49
|
+
/**
|
|
50
|
+
* Publish `address` where a bridge can find it without any environment: the
|
|
51
|
+
* bus-less analogue of `DBUS_SESSION_BUS_ADDRESS`. Returns the file path, or
|
|
52
|
+
* `null` when it could not be written (never fatal — the address is also logged,
|
|
53
|
+
* and an explicit `GJSIFY_DEVTOOLS_ADDRESS` on both sides needs no file).
|
|
54
|
+
*/
|
|
55
|
+
export declare function writeDevtoolsAddressFile(path: string, address: string): string | null;
|
|
56
|
+
/** Delete a previously published address file, so a dead address cannot be dialled. */
|
|
57
|
+
export declare function removeDevtoolsAddressFile(path: string): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/devtools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "In-app DBus devtools control plane for GJS/GTK apps — inspect, screenshot, and drive a running app over org.gjsify.Devtools (gdbus or the MCP bridge)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "lib/esm/index.js",
|
|
@@ -43,11 +43,11 @@
|
|
|
43
43
|
"@girs/gobject-2.0": "^4.1.0",
|
|
44
44
|
"@girs/graphene-1.0": "^4.1.0",
|
|
45
45
|
"@girs/gtk-4.0": "^4.1.0",
|
|
46
|
-
"@gjsify/devtools-protocol": "^0.
|
|
46
|
+
"@gjsify/devtools-protocol": "^0.29.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@gjsify/cli": "^0.
|
|
50
|
-
"@gjsify/unit": "^0.
|
|
49
|
+
"@gjsify/cli": "^0.29.0",
|
|
50
|
+
"@gjsify/unit": "^0.29.0",
|
|
51
51
|
"@types/node": "^25.9.2",
|
|
52
52
|
"typescript": "^6.0.3"
|
|
53
53
|
},
|