@gjsify/devtools 0.27.1 → 0.28.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 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` (or `null` when the env gate is off); `uninstallDevtools(service)` — opt-in lifecycle.
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{captureWidgetPng as o}from"./screenshot.js";import{dumpGSettings as s}from"./gsettings.js";import{activateWidget as c,dumpTree as l,getWidgetProperty as u,listToplevels as d,pathOfWidget as f,resolveWidgetPath as p,widgetType as m}from"./widget-tree.js";import h from"@girs/glib-2.0";import g from"@girs/gtk-4.0";import{GENERIC_METHODS as _,formatDbusErrorMessage as v}from"@gjsify/devtools-protocol";import y from"@girs/gio-2.0";function frameDelay(e){return new Promise(t=>{h.timeout_add(h.PRIORITY_DEFAULT,e,()=>(t(),h.SOURCE_REMOVE))})}async function captureWidgetWhenRenderable(e,t=12,n=50){for(let r=0;r<t;r++){let t=o(e);if(t)return t;await frameDelay(n)}return o(e)}var DevtoolsService=class{constructor(e,t){this._app=e,this._options=t,this._exported=null,this._kinds=new Map,this._extensions=t.extend??[];for(let[e,t]of Object.entries(_))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)return;let n=a(this._extensions.flatMap(e=>e.methodsXml??[])),r=y.DBusExportedObject.wrapJSObject(n,this);r.export(e,t),this._exported=r}unexport(){this._exported?.unexport(),this._exported=null}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:g.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 h.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=o(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(v(`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(d())}DumpTree(e,t){let n=this._resolveRootWidget(e);if(!n)throw Error(v(`not-found`,`no widget at '${e||`active window`}'`));return JSON.stringify(l(n.widget,t>0?t:8,n.path))}GetProperty(e,t){let n=this._resolveRootWidget(e);if(!n)throw Error(v(`not-found`,`no widget at '${e}'`));let r=u(n.widget,t);if(r===void 0)throw Error(v(`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:f(t),name:t.get_name()||null,type:m(t)}:null)}ActivateWidget(e){this._guard(`ActivateWidget`);let t=this._resolveRootWidget(e);if(!t)throw Error(v(`not-found`,`no widget at '${e}'`));return c(t.widget)}DumpGSettings(e){return JSON.stringify(s(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:f(e)??`toplevel:0`}:null}let t=p(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(v(`internal`,`unclassified devtools method '${e}'`));if(t===`mutating`&&this._isPaused())throw Error(v(`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 g.ApplicationWindow?t:null}_requireGroup(e,t){let n=this._actionGroup(e);if(!n)throw Error(v(`unavailable`,`no '${e}' action group`));if(!n.has_action(t))throw Error(v(`not-found`,`unknown action ${e}.${t}`));return n}_parseValue(e){if(!e||e===`null`)return null;try{return JSON.parse(e)}catch{throw Error(v(`invalid-params`,`value_json is not valid JSON: ${e}`))}}};export{DevtoolsService};
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{captureWidgetPng as l}from"./screenshot.js";import{dumpGSettings as u}from"./gsettings.js";import{buildWidgetPath as d,dumpTree as f,getWidgetProperty as p,listToplevels as m,parseWidgetPath as h,pathOfWidget as g,resolveWidgetPath as _,widgetType as v}from"./widget-tree.js";import{DevtoolsService as y}from"./devtools-service.js";import{installDevtools as b,uninstallDevtools as x}from"./install.js";export*from"@gjsify/devtools-protocol";export{y as DevtoolsService,n as activateAction,c as buildDevtoolsIfaceXml,e as buildVariant,d as buildWidgetPath,l as captureWidgetPng,r as changeActionState,i as describeActions,a as dumpCss,u as dumpGSettings,f as dumpTree,p as getWidgetProperty,b as installDevtools,m as listToplevels,h as parseWidgetPath,g as pathOfWidget,o as removeCss,_ as resolveWidgetPath,s as swapCss,x as uninstallDevtools,t as variantKindFor,v as widgetType};
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};
@@ -1 +1 @@
1
- import"./_virtual/_rolldown/runtime.js";import{DevtoolsService as e}from"./devtools-service.js";import t from"@girs/glib-2.0";import{DEVTOOLS_INTERFACE as n}from"@gjsify/devtools-protocol";const r=new Set;function envEnabled(){let e=t.getenv(`GJSIFY_DEVTOOLS`);if(e==null)return!1;let n=e.toLowerCase();return n!==``&&n!==`0`&&n!==`false`}function envInstance(){let e=t.getenv(`GJSIFY_DEVTOOLS_INSTANCE`);return e&&e!==``?e:void 0}function installDevtools(t,i={}){if(!(i.enabled??envEnabled()))return null;let a=new e(t,{...i,instance:i.instance??envInstance()}),o=t.get_dbus_connection(),s=t.get_dbus_object_path();if(o&&s){let e=`${s}/devtools`;a.export(o,e),console.log(`[gjsify-devtools] exported ${n} at ${e} (dest ${t.get_application_id()??`?`})`)}else console.error(`[gjsify-devtools] no session-bus connection yet — call installDevtools() from the GtkApplication "startup" handler`);return r.add(a),a}function uninstallDevtools(e){e.unexport(),r.delete(e)}export{installDevtools,uninstallDevtools};
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
- private _exported;
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
- /** Export the interface at `objectPath` on `connection`. Idempotent. */
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
- /** Tear the interface down. Idempotent. */
22
- unexport(): void;
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
  /**
@@ -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
  /**
@@ -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';
@@ -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;
@@ -0,0 +1,2 @@
1
+ declare const _default: () => Promise<void>;
2
+ export default _default;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/devtools",
3
- "version": "0.27.1",
3
+ "version": "0.28.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.27.1"
46
+ "@gjsify/devtools-protocol": "^0.28.0"
47
47
  },
48
48
  "devDependencies": {
49
- "@gjsify/cli": "^0.27.1",
50
- "@gjsify/unit": "^0.27.1",
49
+ "@gjsify/cli": "^0.28.0",
50
+ "@gjsify/unit": "^0.28.0",
51
51
  "@types/node": "^25.9.2",
52
52
  "typescript": "^6.0.3"
53
53
  },