@gjsify/devtools 0.47.0 → 0.49.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 +21 -4
- package/lib/types/index.d.ts +1 -1
- package/lib/types/screenshot.d.ts +37 -3
- package/lib/types/screenshot.spec.d.ts +2 -0
- package/lib/types/widget-tree.d.ts +27 -1
- package/package.json +4 -4
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. |
|
|
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.
|
|
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,widgetType as C}from"./widget-tree.js";import{DevtoolsService as w}from"./devtools-service.js";import{chooseDevtoolsTransport as T,installDevtools as E,uninstallDevtools as D}from"./install.js";export*from"@gjsify/devtools-protocol";export{l as DevtoolsPeerServerError,w as DevtoolsService,n as activateAction,c as buildDevtoolsIfaceXml,e as buildVariant,g as buildWidgetPath,p as captureWidget,m as captureWidgetPng,r as changeActionState,T as chooseDevtoolsTransport,i as describeActions,a as dumpCss,h as dumpGSettings,_ as dumpTree,v as getWidgetProperty,E 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,D as uninstallDevtools,t as variantKindFor,C 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&&widgetType(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);gtypeName(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){if(!e.get_visible()||!e.get_mapped())return null;let r=!t.type||widgetType(e)===t.type,i=!t.cssClass||e.get_css_classes().includes(t.cssClass);if(r&&i)return n;let a=e.get_first_child(),o=0;for(;a;){let e=findWidgetPath(a,t,`${n}/child:${o}`);if(e)return e;a=a.get_next_sibling(),o++}return null}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:[]};if(t<=0)return r;let
|
|
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&&widgetType(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);gtypeName(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){if(!e.get_visible()||!e.get_mapped())return null;let r=!t.type||widgetType(e)===t.type,i=!t.cssClass||e.get_css_classes().includes(t.cssClass);if(r&&i)return n;let a=e.get_first_child(),o=0;for(;a;){let e=findWidgetPath(a,t,`${n}/child:${o}`);if(e)return e;a=a.get_next_sibling(),o++}return null}const t=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{t as DEFAULT_DUMP_DEPTH,activateWidget,buildWidgetPath,dumpTree,findWidgetPath,getWidgetProperty,listToplevels,parseWidgetPath,parseWidgetSelector,pathOfWidget,resolveWidgetPath,sendKeyToWidget,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. */
|
|
@@ -94,7 +95,23 @@ export declare class DevtoolsService {
|
|
|
94
95
|
ResizeWindow(width: number, height: number): [number, number];
|
|
95
96
|
/** `ListToplevels() -> s` — JSON of live toplevel windows. */
|
|
96
97
|
ListToplevels(): string;
|
|
97
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* `DumpTree(root, depth) -> s` — JSON widget tree from `root` ('' = active window).
|
|
100
|
+
*
|
|
101
|
+
* THE DEFAULT WAS 8 AND DID NOT REACH THE BOTTOM OF AN ORDINARY WINDOW.
|
|
102
|
+
* Measured on a routed application: the navigation page's own
|
|
103
|
+
* `AdwToolbarView` sits at level 7 and its `AdwHeaderBar` below that, so the
|
|
104
|
+
* default answered ZERO header bars for a window that drew one — and the
|
|
105
|
+
* existing devtools vectors' widgets sit at exactly 8, one nesting away from
|
|
106
|
+
* vanishing the same way (#1553).
|
|
107
|
+
*
|
|
108
|
+
* {@link DEFAULT_DUMP_DEPTH} is the new floor and the bound stays: this is the
|
|
109
|
+
* answer to "what did the application draw", read over D-Bus, and an unbounded
|
|
110
|
+
* walk of a deep tree is not free. What changed with it is that the bound now
|
|
111
|
+
* SAYS so — `truncated: true` on any node whose children were not walked — so
|
|
112
|
+
* a caller reading zero children can tell "nothing there" from "I stopped
|
|
113
|
+
* looking", which is the distinction a bigger number alone would not buy.
|
|
114
|
+
*/
|
|
98
115
|
DumpTree(root: string, depth: number): string;
|
|
99
116
|
/** `GetProperty(path, prop) -> s` — JSON value of a widget's GObject property. */
|
|
100
117
|
GetProperty(path: string, prop: string): string;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ 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';
|
|
@@ -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;
|
|
@@ -109,7 +109,33 @@ export declare function parseWidgetSelector(selector: string): WidgetSelector |
|
|
|
109
109
|
* a caller checking only the leaf would happily match one.
|
|
110
110
|
*/
|
|
111
111
|
export declare function findWidgetPath(root: Gtk.Widget, selector: WidgetSelector, basePath: string): string | null;
|
|
112
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* How deep `DumpTree` walks when the caller names no depth.
|
|
114
|
+
*
|
|
115
|
+
* 8 was the first guess and it was measured short: an ordinary Adwaita window
|
|
116
|
+
* puts an `AdwToolbarView` at level 7 and its `AdwHeaderBar` below that, so the
|
|
117
|
+
* default answered zero header bars for a window that drew one (#1553). 40 is
|
|
118
|
+
* chosen to clear a real application tree with room, not to be unbounded — the
|
|
119
|
+
* dump crosses D-Bus, and `truncated` is what makes the remaining bound honest
|
|
120
|
+
* rather than invisible.
|
|
121
|
+
*/
|
|
122
|
+
export declare const DEFAULT_DUMP_DEPTH = 40;
|
|
123
|
+
/**
|
|
124
|
+
* Dump a widget subtree to {@link NodeInfo}, bounded by `maxDepth`.
|
|
125
|
+
*
|
|
126
|
+
* The bound LEAVES A TRACE, and that is the half worth stating: a node cut off
|
|
127
|
+
* here carries `truncated: true`, so a caller reading zero children knows which
|
|
128
|
+
* of the two zeros it got (#1553). A bound itself is right — an unbounded dump of
|
|
129
|
+
* a deep tree over D-Bus is not free — and it is only expensive when it is
|
|
130
|
+
* invisible in the answer.
|
|
131
|
+
*
|
|
132
|
+
* Every mapped node also carries {@link NodeGeometry}: what it was allocated and what
|
|
133
|
+
* it asked for, with `short: true` where the first is less than the second. That is
|
|
134
|
+
* unconditional rather than a flag, and the reason is that the caller who needs it does
|
|
135
|
+
* not yet know which widget to ask about — sweeping the dump for `short` finds the
|
|
136
|
+
* clipped widget, and a flag would have to be turned on by someone who already
|
|
137
|
+
* suspected one.
|
|
138
|
+
*/
|
|
113
139
|
export declare function dumpTree(root: Gtk.Widget, maxDepth: number, basePath: string): NodeInfo;
|
|
114
140
|
/**
|
|
115
141
|
* Read a GObject property by name, trying the original, snake_case and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/devtools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.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",
|
|
@@ -45,12 +45,12 @@
|
|
|
45
45
|
"@girs/gobject-2.0": "4.6.0",
|
|
46
46
|
"@girs/graphene-1.0": "4.6.0",
|
|
47
47
|
"@girs/gtk-4.0": "4.6.0",
|
|
48
|
-
"@gjsify/devtools-protocol": "^0.
|
|
48
|
+
"@gjsify/devtools-protocol": "^0.49.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@gjsify/cli": "^0.
|
|
51
|
+
"@gjsify/cli": "^0.49.0",
|
|
52
52
|
"@gjsify/node-gi": "file:../../node-gi/node-gi",
|
|
53
|
-
"@gjsify/unit": "^0.
|
|
53
|
+
"@gjsify/unit": "^0.49.0",
|
|
54
54
|
"@types/node": "^25.9.2",
|
|
55
55
|
"typescript": "^6.0.3"
|
|
56
56
|
},
|