@gjsify/devtools 0.48.0 → 0.50.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 +50 -1
- package/lib/esm/devtools-service.js +1 -1
- package/lib/esm/index.js +1 -1
- package/lib/esm/screenshot.js +1 -1
- package/lib/esm/widget-tree.js +1 -1
- package/lib/types/devtools-service.d.ts +7 -3
- package/lib/types/index.d.ts +2 -2
- package/lib/types/screenshot.d.ts +37 -3
- package/lib/types/screenshot.spec.d.ts +2 -0
- package/lib/types/widget-tree.d.ts +53 -0
- package/package.json +12 -12
package/README.md
CHANGED
|
@@ -88,6 +88,7 @@ dbus-daemon --session --nofork --address=unix:path=/tmp/devtools-bus.sock &
|
|
|
88
88
|
export DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/devtools-bus.sock
|
|
89
89
|
# …or hand dbus-run-session a config file whose <listen> is a plain socket:
|
|
90
90
|
dbus-run-session --config-file=session.conf -- <command> # <listen>unix:tmpdir=/tmp</listen>
|
|
91
|
+
# a ready-made one lives at packages/node-gi/node-gi/test/session.conf (its `test:dbus` uses it)
|
|
91
92
|
```
|
|
92
93
|
|
|
93
94
|
Prefer the peer transport; the above is only for tooling that must have a bus NAME.
|
|
@@ -103,12 +104,60 @@ Prefer the peer transport; the above is only for tooling that must have a bus NA
|
|
|
103
104
|
|
|
104
105
|
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
106
|
|
|
107
|
+
### One export per CONNECTION, and flags per address FAMILY
|
|
108
|
+
|
|
109
|
+
`DevtoolsService` keys its `Gio.DBusExportedObject`s **by connection**. A peer server hands out one connection per client and keeps no shared registry, so a single export slot served the FIRST client and left every later one seeing `UnknownMethod` for all 26 methods — the bug that made this per-connection.
|
|
110
|
+
|
|
111
|
+
The flag follows the address family, not the OS: `unix:` gets `AUTHENTICATION_REQUIRE_SAME_USER` (the `EXTERNAL` uid match above), and `nonce-tcp:` MUST get `NONE`. Measured on gjs 1.88.1, `REQUIRE_SAME_USER` rejects every TCP client with *"Unexpected lack of content when trying to read a line"*, because TCP carries no peer credentials — which is why the nonce file's 0600 is the secret there.
|
|
112
|
+
|
|
113
|
+
win32 is detected as `GLib.DIR_SEPARATOR === 92` — GLib's own introspected compile-time constant, so there is no `uname` probe and no extra dependency ([ADR 0018](../../../docs/adr/0018-os-axis-declaration.md)).
|
|
114
|
+
|
|
115
|
+
### The published address is a CLAIM, and the bridge VERIFIES it
|
|
116
|
+
|
|
117
|
+
The address file (`devtoolsAddressFilePath`) outranks the session bus, because it is positive evidence that an app of that id is listening NOW. Nothing makes it true, though: it is removed on `unexport()` and on `GApplication::shutdown`, and shutdown covers SIGTERM but **not** SIGINT or SIGKILL. Worse off Linux — on macOS and Windows `GLib.get_user_runtime_dir()` degrades to the user CACHE directory, where a leftover file survives REBOOTS.
|
|
118
|
+
|
|
119
|
+
So `connectToDevtools` dials, then falls back: a published address that does not answer (`G_IO_ERROR_NOT_FOUND`, measured) is DELETED and resolution continues down the precedence to the session bus, or to the diagnostic naming all three ways in — never a raw localised GIO error out of `runDevtoolsMcp`'s first statement.
|
|
120
|
+
|
|
121
|
+
An EXPLICIT `--address` / `GJSIFY_DEVTOOLS_ADDRESS` does **not** fall back. It is pinned on both sides, so a dead one has to be reported rather than routed around.
|
|
122
|
+
|
|
123
|
+
A peer call carries no destination bus name at all, so `nameHasOwner` / `listInstances` answer from the connection instead of from a daemon.
|
|
124
|
+
|
|
106
125
|
## Generic methods (out of the box)
|
|
107
126
|
|
|
108
|
-
|
|
127
|
+
| method | signature | what it does |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| `Screenshot` | `(scope: s) -> ay` | PNG bytes. `scope` `''`/`window` shoots the active window; a widget path shoots ONE widget. |
|
|
130
|
+
| `DumpTree` | `-> s` | the widget tree as JSON, with stable `toplevel:N/child:M` paths. |
|
|
131
|
+
| `GetProperty` | `(path, prop) -> s` | read one property off the widget at `path`. |
|
|
132
|
+
| `GetFocused` / `ListToplevels` | `-> s` | the focused widget's path; the toplevel list. |
|
|
133
|
+
| `FindWidget` | `(selector) -> s` | first VISIBLE+mapped match for `Type`, `:css-class` or `Type:css-class`, depth-first from the active window; `''` when none. `Type` matches IS-A. |
|
|
134
|
+
| `SendKey` | `(accel, path) -> b` | deliver a key to a widget's key controllers; an empty path means the FOCUSED widget. |
|
|
135
|
+
| `ActivateWidget` | `(path) -> b` | click-drive: `gtk_widget_activate` for Button/Entry/Toggle, with a `GtkListBox` select-row / `row-activated` fallback for nav and preference rows. |
|
|
136
|
+
| `GetStatus` | `-> s` | liveness + whatever extensions contribute. |
|
|
137
|
+
| `ListActions` / `ActivateAction` / `ChangeActionState` | | the GAction bridge. |
|
|
138
|
+
| `ResizeWindow` / `PresentWindow` | | window control. |
|
|
139
|
+
| `DumpCss` / `SwapCss` | | live CSS hot-swap. |
|
|
140
|
+
| `DumpGSettings` | `-> s` | the app's GSettings. |
|
|
109
141
|
|
|
110
142
|
GActions are auto-bridged into the command registry (handling the `Adw.ApplicationWindow` non-export gotcha via `winActionGroup`).
|
|
111
143
|
|
|
144
|
+
Three of those need their reason stated, because the obvious use is the wrong one:
|
|
145
|
+
|
|
146
|
+
- **`FindWidget` exists because widget paths are POSITIONAL.** A `toplevel:0/child:3` written into a script is wrong the moment a widget is inserted above it, and every click-driving caller was re-walking `DumpTree` JSON in its own language to avoid that. **Its `Type` half matches IS-A**, because an application subclassing `Adw.Dialog` or `Gtk.Box` is the ordinary way to write a GTK app, and the exact-GType comparison excluded exactly that: a rig waiting for `AdwDialog` reported "opened no AdwDialog" for a dialog the same run's `DumpTree` showed mapped and visible (#1582). An EXACT type still wins over a subclass **within one search**, so every selector that resolved before the widening resolves to the same path after it. `g_type_is_a` also answers for an INTERFACE, so `GtkOrientable` matches every box and `GtkAccessible` matches the first visible widget in any tree — name a capability on purpose, not by accident. The same is-a reading holds `ActivateWidget`'s `GtkListBox` parent check and `SendKey`'s `GtkEventControllerKey` check — `widgetIsA` is the one helper all three call.
|
|
147
|
+
- **`SendKey` is the half of headless driving `ActivateWidget` does not cover.** Nothing could be TYPED, so every `Gtk.EventControllerKey` handler was unverifiable. It emits `key-pressed` on the widget's own controllers rather than fabricating a `Gdk.Event` — GTK4 made events opaque with no public constructor — so it proves the HANDLER, not GDK's routing. Pair it with `GetProperty(path, "focusable")`: an unfocusable widget swallows every real key silently.
|
|
148
|
+
- **`ActivateWidget` is the mutating counterpart of `GetProperty`.**
|
|
149
|
+
|
|
150
|
+
`opts.extend` adds app-specific methods (§ below); `opts.instance` / `GJSIFY_DEVTOOLS_INSTANCE` scope a multi-instance app.
|
|
151
|
+
|
|
152
|
+
### Calling one by hand
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
gdbus call --session --dest <app-id> --object-path <path>/devtools \
|
|
156
|
+
--method org.gjsify.Devtools.Screenshot ""
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
That returns an `ay` variant. `gdbus` itself cannot save binary, so unpack it from a tiny GJS caller.
|
|
160
|
+
|
|
112
161
|
## App-specific methods — a `DevtoolsExtension`
|
|
113
162
|
|
|
114
163
|
Add your own methods without forking the core. The `<method>` XML is merged into the interface, handlers attach as DBus methods, and each method declares a kind the pause guard enforces.
|
|
@@ -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{removeDevtoolsAddressFile as o}from"./peer-transport.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{captureWidget as s}from"./screenshot.js";import{dumpGSettings as c}from"./gsettings.js";import{activateWidget as l,dumpTree as u,findWidgetPath as d,getWidgetProperty as f,listToplevels as p,parseWidgetSelector as m,pathOfWidget as h,resolveWidgetPath as g,sendKeyToWidget as _,widgetType as v}from"./widget-tree.js";import y from"gi://GLib?version=2.0";import b from"gi://Gtk?version=4.0";import{GENERIC_METHODS as x,formatDbusErrorMessage as S}from"@gjsify/devtools-protocol";import C from"gi://Gio?version=2.0";function isActiveWindowScope(e){return!e||e===`window`||e===`active`}function frameDelay(e){return new Promise(t=>{y.timeout_add(y.PRIORITY_DEFAULT,e,()=>(t(),y.SOURCE_REMOVE))})}async function captureWidgetWhenRenderable(e,t=12,n=50){let r=null;for(let i=0;i<t;i++){let t=s(e);if(t.png)return{png:t.png,blocker:null};r=t.blocker,await frameDelay(n)}let i=s(e);return{png:i.png,blocker:i.png?null:i.blocker??r}}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(x))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=C.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:b.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 n=typeof e?.[0]==`string`?e[0]:``,r=await this._captureScopePng(n);t.return_value(new y.Variant(`(ay)`,[r]))}catch(e){t.return_dbus_error(`org.gjsify.Devtools.Error.Screenshot`,e instanceof Error?e.message:String(e))}}async _captureScopePng(e){let t=this._resolveRootWidget(e);if(!t){if(isActiveWindowScope(e))return new Uint8Array;throw Error(S(`not-found`,`no widget at '${e}'`))}let n=s(t.widget);if(n.png)return n.png;let r=t.widget.get_root();r instanceof b.Window&&r.present();let i=await captureWidgetWhenRenderable(t.widget);return i.png?i.png:(console.warn(`[devtools] Screenshot('${e}') has no image: ${i.blocker??`unknown`} after 12 tries. Empty bytes follow.`),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(S(`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(p())}DumpTree(e,t){let n=this._resolveRootWidget(e);if(!n)throw Error(S(`not-found`,`no widget at '${e||`active window`}'`));return JSON.stringify(u(n.widget,t>0?t:40,n.path))}GetProperty(e,t){let n=this._resolveRootWidget(e);if(!n)throw Error(S(`not-found`,`no widget at '${e}'`));let r=f(n.widget,t);if(r===void 0)throw Error(S(`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:h(t),name:t.get_name()||null,type:v(t)}:null)}ActivateWidget(e){this._guard(`ActivateWidget`);let t=this._resolveRootWidget(e);if(!t)throw Error(S(`not-found`,`no widget at '${e}'`));return l(t.widget)}FindWidget(e){let t=m(e);if(!t)throw Error(S(`invalid-params`,`empty selector '${e}'`));let n=this._resolveRootWidget(``);return n?d(n.widget,t,n.path)??``:``}SendKey(e,t){this._guard(`SendKey`);let[n,r,i]=b.accelerator_parse(e);if(!n)throw Error(S(`invalid-params`,`cannot parse accelerator '${e}'`));let a;if(a=t?this._resolveRootWidget(t)?.widget??null:this._app.get_active_window()?.get_focus()??null,!a)throw Error(S(`not-found`,`no widget at '${t||`focus`}'`));return _(a,r,i??0)}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(isActiveWindowScope(e)){let e=this._app.get_active_window();return e?{widget:e,path:h(e)??`toplevel:0`}:null}let t=g(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(S(`internal`,`unclassified devtools method '${e}'`));if(t===`mutating`&&this._isPaused())throw Error(S(`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 b.ApplicationWindow?t:null}_requireGroup(e,t){let n=this._actionGroup(e);if(!n)throw Error(S(`unavailable`,`no '${e}' action group`));if(!n.has_action(t))throw Error(S(`not-found`,`unknown action ${e}.${t}`));return n}_parseValue(e){if(!e||e===`null`)return null;try{return JSON.parse(e)}catch{throw Error(S(`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{DevtoolsPeerServerError as l,removeDevtoolsAddressFile as u,startDevtoolsPeerServer as d,writeDevtoolsAddressFile as f}from"./peer-transport.js";import{captureWidgetPng as
|
|
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{captureWidget as p,captureWidgetPng as m}from"./screenshot.js";import{dumpGSettings as h}from"./gsettings.js";import{buildWidgetPath as g,dumpTree as _,getWidgetProperty as v,listToplevels as y,parseWidgetPath as b,pathOfWidget as x,resolveWidgetPath as S,widgetIsA as C,widgetType as w}from"./widget-tree.js";import{DevtoolsService as T}from"./devtools-service.js";import{chooseDevtoolsTransport as E,installDevtools as D,uninstallDevtools as O}from"./install.js";export*from"@gjsify/devtools-protocol";export{l as DevtoolsPeerServerError,T as DevtoolsService,n as activateAction,c as buildDevtoolsIfaceXml,e as buildVariant,g as buildWidgetPath,p as captureWidget,m as captureWidgetPng,r as changeActionState,E as chooseDevtoolsTransport,i as describeActions,a as dumpCss,h as dumpGSettings,_ as dumpTree,v as getWidgetProperty,D as installDevtools,y as listToplevels,b as parseWidgetPath,x as pathOfWidget,o as removeCss,u as removeDevtoolsAddressFile,S as resolveWidgetPath,d as startDevtoolsPeerServer,s as swapCss,O as uninstallDevtools,t as variantKindFor,C as widgetIsA,w as widgetType,f as writeDevtoolsAddressFile};
|
package/lib/esm/screenshot.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import"./_virtual/_rolldown/runtime.js";import e from"gi://Gtk?version=4.0";import t from"gi://Graphene?version=1.0";function
|
|
1
|
+
import"./_virtual/_rolldown/runtime.js";import e from"gi://Gtk?version=4.0";import t from"gi://Graphene?version=1.0";function captureWidget(n){let r=n.get_native()?.get_renderer();if(!r)return{png:null,blocker:`no-renderer`};let i=n.get_width(),a=n.get_height();if(i<=0||a<=0)return{png:null,blocker:`zero-size`};let o=e.WidgetPaintable.new(n),s=e.Snapshot.new();o.snapshot(s,i,a);let c=s.to_node();if(!c)return{png:null,blocker:`empty-snapshot`};let l=new t.Rect;l.init(0,0,i,a);let u=r.render_texture(c,l).save_to_png_bytes().get_data();return u?{png:new Uint8Array(u)}:{png:null,blocker:`empty-png`}}function captureWidgetPng(e){return captureWidget(e).png}export{captureWidget,captureWidgetPng};
|
package/lib/esm/widget-tree.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import"./_virtual/_rolldown/runtime.js";import e from"gi://Gtk?version=4.0";function parseWidgetPath(e){let t=e.split(`/`).filter(e=>e.length>0);if(t.length===0)return null;let n=t[0].match(/^toplevel:(\d+)$/);if(!n)return null;let r=[];for(let e of t.slice(1)){let t=e.match(/^child:(\d+)$/);if(!t)return null;r.push(Number(t[1]))}return{toplevel:Number(n[1]),children:r}}function buildWidgetPath(e,t){return[`toplevel:${e}`,...t.map(e=>`child:${e}`)].join(`/`)}function widgetType(e){return gtypeName(e)??`GtkWidget`}function gtypeName(e){let t=e;return typeof t?.$typeName==`string`&&t.$typeName.length>0?t.$typeName:t?.constructor?.$gtype?.name??null}function activateWidget(e){let t=e;if(typeof t.activate==`function`&&t.activate()===!0)return!0;let n=typeof t.get_parent==`function`?t.get_parent():null;if(n&&
|
|
1
|
+
import"./_virtual/_rolldown/runtime.js";import e from"gi://Gtk?version=4.0";import t from"gi://GObject?version=2.0";function parseWidgetPath(e){let t=e.split(`/`).filter(e=>e.length>0);if(t.length===0)return null;let n=t[0].match(/^toplevel:(\d+)$/);if(!n)return null;let r=[];for(let e of t.slice(1)){let t=e.match(/^child:(\d+)$/);if(!t)return null;r.push(Number(t[1]))}return{toplevel:Number(n[1]),children:r}}function buildWidgetPath(e,t){return[`toplevel:${e}`,...t.map(e=>`child:${e}`)].join(`/`)}function widgetType(e){return gtypeName(e)??`GtkWidget`}function gtypeName(e){let t=e;return typeof t?.$typeName==`string`&&t.$typeName.length>0?t.$typeName:t?.constructor?.$gtype?.name??null}function widgetIsA(e,n){let r=gtypeName(e);if(r===null||!n)return!1;if(r===n)return!0;let i=t.type_from_name(r),a=t.type_from_name(n);return!i||!a?!1:t.type_is_a(i,a)}function activateWidget(e){let t=e;if(typeof t.activate==`function`&&t.activate()===!0)return!0;let n=typeof t.get_parent==`function`?t.get_parent():null;if(n&&widgetIsA(n,`GtkListBox`)){let t=n,r=!1;return typeof t.select_row==`function`&&(t.select_row(e),r=!0),typeof t.emit==`function`&&(t.emit(`row-activated`,e),r=!0),r}return!1}function sendKeyToWidget(e,t,n){let r=e.observe_controllers(),i=!1;for(let e=0;e<r.get_n_items();e++){let a=r.get_item(e);widgetIsA(a,`GtkEventControllerKey`)&&(a.emit(`key-pressed`,t,0,n),i=!0)}return i}function nthChild(e,t){let n=e.get_first_child(),r=0;for(;n&&r<t;)n=n.get_next_sibling(),r++;return r===t?n:null}function listToplevels(){let t=e.Window.get_toplevels(),n=t.get_n_items(),r=[];for(let e=0;e<n;e++){let n=t.get_item(e);n&&r.push({path:`toplevel:${e}`,type:widgetType(n),title:n.get_title?.()??null,mapped:n.get_mapped(),focused:n.is_active??!1})}return r}function resolveWidgetPath(t){let n=parseWidgetPath(t);if(!n)return null;let r=e.Window.get_toplevels().get_item(n.toplevel);if(!r)return null;for(let e of n.children)if(r=nthChild(r,e),!r)return null;return r}function parseWidgetSelector(e){let t=e.indexOf(`:`),n=(t<0?e:e.slice(0,t)).trim(),r=(t<0?``:e.slice(t+1)).trim();return!n&&!r?null:{type:n,cssClass:r}}function findWidgetPath(e,t,n){let r={path:null};return walkForSelector(e,t,n,r)??r.path}function walkForSelector(e,t,n,r){if(!e.get_visible()||!e.get_mapped())return null;if(!t.cssClass||e.get_css_classes().includes(t.cssClass)){if(!t.type||widgetType(e)===t.type)return n;r.path===null&&widgetIsA(e,t.type)&&(r.path=n)}let i=e.get_first_child(),a=0;for(;i;){let e=walkForSelector(i,t,`${n}/child:${a}`,r);if(e)return e;i=i.get_next_sibling(),a++}return null}const n=40;function geometryOf(t){if(!t.get_mapped())return;let n=t.get_allocation(),r=n.width+t.get_margin_start()+t.get_margin_end(),i=n.height+t.get_margin_top()+t.get_margin_bottom(),[a,o]=t.measure(e.Orientation.HORIZONTAL,-1),s=r>0?Math.max(r,a):-1,[c,l]=t.measure(e.Orientation.VERTICAL,s),u={width:r,height:i,widthRequest:[a,o],heightRequest:[c,l]};return(r<a||i<c)&&(u.short=!0),u}function dumpTree(e,t,n){let r={path:n,type:widgetType(e),name:e.get_name()||null,cssClasses:e.get_css_classes(),mapped:e.get_mapped(),visible:e.get_visible(),children:[]},i=geometryOf(e);if(i!==void 0&&(r.geometry=i),t<=0)return e.get_first_child()&&(r.truncated=!0),r;let a=e.get_first_child(),o=0;for(;a;)r.children.push(dumpTree(a,t-1,`${n}/child:${o}`)),a=a.get_next_sibling(),o++;return r}function jsonSafe(e){let t=typeof e;return e==null||t===`string`||t===`number`||t===`boolean`?e??null:`[${t}]`}function getWidgetProperty(e,t){let n=e,r=t.replace(/-/g,`_`),i=t.replace(/[-_]([a-z])/g,(e,t)=>t.toUpperCase());for(let e of[t,r,i])if(e in n)return jsonSafe(n[e])}function childIndex(e,t){let n=e.get_first_child(),r=0;for(;n;){if(n===t)return r;n=n.get_next_sibling(),r++}return-1}function pathOfWidget(t){let n=[],r=t,i=r.get_parent();for(;i;){let e=childIndex(i,r);if(e<0)return null;n.unshift(e),r=i,i=r.get_parent()}let a=e.Window.get_toplevels(),o=a.get_n_items();for(let e=0;e<o;e++)if(a.get_item(e)===r)return buildWidgetPath(e,n);return null}export{n as DEFAULT_DUMP_DEPTH,activateWidget,buildWidgetPath,dumpTree,findWidgetPath,getWidgetProperty,listToplevels,parseWidgetPath,parseWidgetSelector,pathOfWidget,resolveWidgetPath,sendKeyToWidget,widgetIsA,widgetType};
|
|
@@ -77,9 +77,10 @@ export declare class DevtoolsService {
|
|
|
77
77
|
ScreenshotAsync(params: unknown[], invocation: Gio.DBusMethodInvocation): Promise<void>;
|
|
78
78
|
/**
|
|
79
79
|
* Capture `scope` to PNG, warming up across frames: a just-launched or mid-layout
|
|
80
|
-
* window yields a zero-size GSK frame, so the first
|
|
81
|
-
*
|
|
82
|
-
*
|
|
80
|
+
* window yields a zero-size GSK frame, so the first capture can be empty. Empty
|
|
81
|
+
* bytes remain the genuine-failure signal, for a window that never realises at all
|
|
82
|
+
* — and the reason now goes to the log beside them, because the wire cannot carry
|
|
83
|
+
* it and a silent empty image gets a cause invented for it.
|
|
83
84
|
*/
|
|
84
85
|
private _captureScopePng;
|
|
85
86
|
/** `ListActions() -> s` — JSON of the `app.*` + `win.*` actions. */
|
|
@@ -134,6 +135,9 @@ export declare class DevtoolsService {
|
|
|
134
135
|
* rather than an error for "no match": not finding a widget is an ordinary
|
|
135
136
|
* answer a caller acts on (wait and retry, or report), not a fault.
|
|
136
137
|
*
|
|
138
|
+
* The type half matches IS-A, so `AdwDialog` reaches an application's own
|
|
139
|
+
* `Adw.Dialog` subclass, and an INTERFACE name (`GtkOrientable`, `GtkEditable`) matches
|
|
140
|
+
* every implementor; an exact-type match is still preferred over any of them.
|
|
137
141
|
* Invisible and unmapped subtrees are skipped — see {@link findWidgetPath}. */
|
|
138
142
|
FindWidget(selector: string): string;
|
|
139
143
|
/** `SendKey(accelerator, path) -> b` — deliver a key to the key controllers of the
|
package/lib/types/index.d.ts
CHANGED
|
@@ -4,12 +4,12 @@ export { DevtoolsService } from './devtools-service.js';
|
|
|
4
4
|
export type { DevtoolsExtension, InstallDevtoolsOptions } from './extension.js';
|
|
5
5
|
export { DevtoolsPeerServerError, removeDevtoolsAddressFile, startDevtoolsPeerServer, writeDevtoolsAddressFile, } from './peer-transport.js';
|
|
6
6
|
export type { DevtoolsPeerServer } from './peer-transport.js';
|
|
7
|
-
export { captureWidgetPng } from './screenshot.js';
|
|
7
|
+
export { captureWidget, captureWidgetPng, type CaptureBlocker, type CaptureResult } from './screenshot.js';
|
|
8
8
|
export { buildVariant, variantKindFor } from './gvariant.js';
|
|
9
9
|
export type { VariantKind } from './gvariant.js';
|
|
10
10
|
export { activateAction, changeActionState, describeActions } from './actions.js';
|
|
11
11
|
export { buildDevtoolsIfaceXml } from './devtools-iface.js';
|
|
12
|
-
export { buildWidgetPath, dumpTree, getWidgetProperty, listToplevels, parseWidgetPath, pathOfWidget, resolveWidgetPath, widgetType, } from './widget-tree.js';
|
|
12
|
+
export { buildWidgetPath, dumpTree, getWidgetProperty, listToplevels, parseWidgetPath, pathOfWidget, resolveWidgetPath, widgetIsA, widgetType, } from './widget-tree.js';
|
|
13
13
|
export { dumpCss, removeCss, swapCss } from './css.js';
|
|
14
14
|
export { dumpGSettings } from './gsettings.js';
|
|
15
15
|
export * from '@gjsify/devtools-protocol';
|
|
@@ -1,12 +1,46 @@
|
|
|
1
1
|
import Gtk from 'gi://Gtk?version=4.0';
|
|
2
|
+
/**
|
|
3
|
+
* Why a capture could not be made. Four distinct absences, and telling them apart is
|
|
4
|
+
* the whole point of this type.
|
|
5
|
+
*
|
|
6
|
+
* A `null` with no reason is how this file used to answer, and the service above turns
|
|
7
|
+
* a failed capture into ZERO BYTES on the wire — a deliberate contract, so the MCP
|
|
8
|
+
* tool can retry a window that is not up yet. The two together are silent, and a
|
|
9
|
+
* silent empty PNG is read by whoever receives it as a statement about what the tool
|
|
10
|
+
* cannot do: MEASURED consequence, an empty reply from a window with a playing video
|
|
11
|
+
* was published in a consumer's README as "the devtools `Screenshot` returns nothing
|
|
12
|
+
* with a live video texture", which is false — the same window answers 308 714 bytes.
|
|
13
|
+
* The capture had declined for one of the other three reasons and said nothing.
|
|
14
|
+
*/
|
|
15
|
+
export type CaptureBlocker =
|
|
16
|
+
/** Not realised: no `Gtk.Native`, or a native with no `Gsk.Renderer` yet. */
|
|
17
|
+
'no-renderer'
|
|
18
|
+
/** Realised but not allocated — mid-layout, or a window between two sizes. */
|
|
19
|
+
| 'zero-size'
|
|
20
|
+
/** Nothing to draw: `Gtk.Snapshot.to_node()` answered null. */
|
|
21
|
+
| 'empty-snapshot'
|
|
22
|
+
/** Rendered, and the PNG serialiser handed back no bytes. */
|
|
23
|
+
| 'empty-png';
|
|
24
|
+
/** A capture, or the reason there is none. */
|
|
25
|
+
export type CaptureResult = {
|
|
26
|
+
readonly png: Uint8Array;
|
|
27
|
+
readonly blocker?: undefined;
|
|
28
|
+
} | {
|
|
29
|
+
readonly png: null;
|
|
30
|
+
readonly blocker: CaptureBlocker;
|
|
31
|
+
};
|
|
2
32
|
/**
|
|
3
33
|
* Render a GTK widget — typically the top-level window — to PNG bytes, fully
|
|
4
34
|
* in-process via the GSK renderer. No external screenshot tools, no
|
|
5
35
|
* compositor portal: the widget's own `Gsk.Renderer` rasterises a
|
|
6
36
|
* `Gtk.WidgetPaintable` snapshot to a `Gdk.Texture`, serialised to PNG.
|
|
7
37
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
38
|
+
* A LIVE VIDEO IS NOT ONE OF THE REASONS THIS DECLINES, which is worth stating where
|
|
39
|
+
* a reader would look for it: measured on GTK 4.22.4 with `GskVulkanRenderer`, a
|
|
40
|
+
* `gtk4paintablesink` paintable in a `Gtk.Picture` captures at 202 645 bytes for the
|
|
41
|
+
* picture alone and 204 865 for its window, both with `videotestsrc` and with a real
|
|
42
|
+
* HLS stream. The renderer downloads the texture like any other.
|
|
11
43
|
*/
|
|
44
|
+
export declare function captureWidget(widget: Gtk.Widget): CaptureResult;
|
|
45
|
+
/** The bytes alone, for callers that have nothing to do with the reason. */
|
|
12
46
|
export declare function captureWidgetPng(widget: Gtk.Widget): Uint8Array | null;
|
|
@@ -25,6 +25,32 @@ export declare function buildWidgetPath(toplevel: number, children: readonly num
|
|
|
25
25
|
* `$typeName` — prefer it when present. (GJS has no `$typeName` on instances.)
|
|
26
26
|
*/
|
|
27
27
|
export declare function widgetType(widget: Gtk.Widget): string;
|
|
28
|
+
/**
|
|
29
|
+
* Does `object`'s runtime GType derive from `typeName` — the type itself, an ancestor, or an
|
|
30
|
+
* INTERFACE it implements?
|
|
31
|
+
*
|
|
32
|
+
* The interface arm is `g_type_is_a`'s and is kept rather than filtered out: `GtkOrientable`,
|
|
33
|
+
* `GtkEditable` and `GtkAccessible` describe what a widget can DO, which is the same question
|
|
34
|
+
* a selector asks, and a rig author who tries one is not making a mistake. It does mean
|
|
35
|
+
* `GtkAccessible` and `GtkBuildable` match the first visible widget in any GTK tree — every
|
|
36
|
+
* widget implements both — so a selector that broad answers a widget rather than nothing.
|
|
37
|
+
*
|
|
38
|
+
* **The name comparison comes first and is the whole answer for a type GObject does not
|
|
39
|
+
* know.** That covers a unit-test shape and a runtime that never registered the type, and
|
|
40
|
+
* it is what makes every exact-name spec in this module still mean what it says.
|
|
41
|
+
*
|
|
42
|
+
* Where GObject does know both names, `g_type_is_a` answers. Registration is a RUNTIME
|
|
43
|
+
* fact and not a static one — measured under GJS 1.88.1, `GObject.type_from_name('AdwDialog')`
|
|
44
|
+
* answers null until something in JS touches `Adw.Dialog`, and registering a SUBCLASS
|
|
45
|
+
* registers its whole ancestor chain. So the lookup happens per call and is never cached:
|
|
46
|
+
* the base type of a live instance is always registered (the instance's class registered
|
|
47
|
+
* it), and a name that resolves to nothing today may resolve a moment later.
|
|
48
|
+
*
|
|
49
|
+
* Written against the NAME rather than `instanceof` for the same reason as
|
|
50
|
+
* {@link widgetType}: under node-gi a returned handle is a GENERIC wrapper, so
|
|
51
|
+
* `instanceof Gtk.ListBox` answers false for an object that is one.
|
|
52
|
+
*/
|
|
53
|
+
export declare function widgetIsA(object: unknown, typeName: string): boolean;
|
|
28
54
|
/**
|
|
29
55
|
* Activate a widget — how external tooling click-drives a running GUI: resolve a
|
|
30
56
|
* widget by path, then activate it. Two paths, tried in order:
|
|
@@ -107,6 +133,26 @@ export declare function parseWidgetSelector(selector: string): WidgetSelector |
|
|
|
107
133
|
* split view — and activating one of those proves nothing about what a user can reach. Skipping the
|
|
108
134
|
* subtree as well as the node matters: the children of an unmapped stack page are unmapped too, but
|
|
109
135
|
* a caller checking only the leaf would happily match one.
|
|
136
|
+
*
|
|
137
|
+
* ## The type half matches IS-A, with the exact type preferred
|
|
138
|
+
*
|
|
139
|
+
* `Type` used to mean the exact GType name, which excludes the ordinary way GTK applications
|
|
140
|
+
* are written: three of bauplaner's six dialogs are `Adw.Dialog` SUBCLASSES, so a rig waiting
|
|
141
|
+
* for `AdwDialog` reported "opened no AdwDialog" for a dialog the tree dump showed mapped and
|
|
142
|
+
* visible (#1582). The failure reads like a defect in the consumer, and the workaround every
|
|
143
|
+
* consumer invents — matching a STYLE CLASS instead — addresses the widget by how it looks
|
|
144
|
+
* rather than by what it is.
|
|
145
|
+
*
|
|
146
|
+
* An EXACT match anywhere in THIS CALL's subtree still wins over a subclass match, even one
|
|
147
|
+
* earlier in reading order, and that ordering is the compatibility guarantee: every selector
|
|
148
|
+
* that resolved before this widening resolves to the SAME path after it, and the subclass leg
|
|
149
|
+
* is consulted only where the answer used to be none. It costs one full walk in the case that
|
|
150
|
+
* used to walk the tree anyway — the miss.
|
|
151
|
+
*
|
|
152
|
+
* PER CALL is the whole scope of that guarantee, and a caller sweeping several roots has to
|
|
153
|
+
* say so itself: `FindWidget` passes ONE root (the active window), but a consumer looping over
|
|
154
|
+
* `ListToplevels` gets a subclass hit in an early toplevel ahead of an exact hit in a later
|
|
155
|
+
* one. Collect the per-root answers and prefer the exact one, or pass the exact type.
|
|
110
156
|
*/
|
|
111
157
|
export declare function findWidgetPath(root: Gtk.Widget, selector: WidgetSelector, basePath: string): string | null;
|
|
112
158
|
/**
|
|
@@ -128,6 +174,13 @@ export declare const DEFAULT_DUMP_DEPTH = 40;
|
|
|
128
174
|
* of the two zeros it got (#1553). A bound itself is right — an unbounded dump of
|
|
129
175
|
* a deep tree over D-Bus is not free — and it is only expensive when it is
|
|
130
176
|
* invisible in the answer.
|
|
177
|
+
*
|
|
178
|
+
* Every mapped node also carries {@link NodeGeometry}: what it was allocated and what
|
|
179
|
+
* it asked for, with `short: true` where the first is less than the second. That is
|
|
180
|
+
* unconditional rather than a flag, and the reason is that the caller who needs it does
|
|
181
|
+
* not yet know which widget to ask about — sweeping the dump for `short` finds the
|
|
182
|
+
* clipped widget, and a flag would have to be turned on by someone who already
|
|
183
|
+
* suspected one.
|
|
131
184
|
*/
|
|
132
185
|
export declare function dumpTree(root: Gtk.Widget, maxDepth: number, basePath: string): NodeInfo;
|
|
133
186
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/devtools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.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",
|
|
@@ -37,20 +37,20 @@
|
|
|
37
37
|
"mcp"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@girs/adw-1": "
|
|
41
|
-
"@girs/gdk-4.0": "
|
|
42
|
-
"@girs/gio-2.0": "
|
|
43
|
-
"@girs/gjs": "
|
|
44
|
-
"@girs/glib-2.0": "
|
|
45
|
-
"@girs/gobject-2.0": "
|
|
46
|
-
"@girs/graphene-1.0": "
|
|
47
|
-
"@girs/gtk-4.0": "
|
|
48
|
-
"@gjsify/devtools-protocol": "^0.
|
|
40
|
+
"@girs/adw-1": "5.0.0",
|
|
41
|
+
"@girs/gdk-4.0": "5.0.0",
|
|
42
|
+
"@girs/gio-2.0": "5.0.0",
|
|
43
|
+
"@girs/gjs": "5.0.0",
|
|
44
|
+
"@girs/glib-2.0": "5.0.0",
|
|
45
|
+
"@girs/gobject-2.0": "5.0.0",
|
|
46
|
+
"@girs/graphene-1.0": "5.0.0",
|
|
47
|
+
"@girs/gtk-4.0": "5.0.0",
|
|
48
|
+
"@gjsify/devtools-protocol": "^0.50.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@gjsify/cli": "^0.
|
|
51
|
+
"@gjsify/cli": "^0.50.0",
|
|
52
52
|
"@gjsify/node-gi": "file:../../node-gi/node-gi",
|
|
53
|
-
"@gjsify/unit": "^0.
|
|
53
|
+
"@gjsify/unit": "^0.50.0",
|
|
54
54
|
"@types/node": "^25.9.2",
|
|
55
55
|
"typescript": "^6.0.3"
|
|
56
56
|
},
|