@gjsify/devtools 0.63.0 → 0.63.1-next.236

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
@@ -41,6 +41,8 @@ gdbus call --session -d org.example.App -o /org/example/App/devtools \
41
41
  -m org.gjsify.Devtools.GetStatus
42
42
  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
+ gdbus call --session -d org.example.App -o /org/example/App/devtools \
45
+ -m org.gjsify.Devtools.ScreenshotScaled 'window' 2.0 # the same, at 2× device pixels
44
46
  ```
45
47
 
46
48
  ## No session bus? (macOS, Windows)
@@ -126,7 +128,8 @@ A peer call carries no destination bus name at all, so `nameHasOwner` / `listIns
126
128
 
127
129
  | method | signature | what it does |
128
130
  |---|---|---|
129
- | `Screenshot` | `(scope: s) -> ay` | PNG bytes. `scope` `''`/`window` shoots the active window; a widget path shoots ONE widget. |
131
+ | `Screenshot` | `(scope: s) -> ay` | PNG bytes at the widget's OWN scale factor, so a HiDPI window captures at the resolution it is drawn at. `scope` `''`/`window` shoots the active window; a widget path shoots ONE widget. |
132
+ | `ScreenshotScaled` | `(scope: s, scale: d) -> ay` | the same capture at an explicit device-pixel scale; `scale <= 0` means the widget's own factor. |
130
133
  | `DumpTree` | `-> s` | the widget tree as JSON, with stable `toplevel:N/child:M` paths. |
131
134
  | `GetProperty` | `(path, prop) -> s` | read one property off the widget at `path`. |
132
135
  | `GetFocused` / `ListToplevels` | `-> s` | the focused widget's path; the toplevel list. |
@@ -158,6 +161,7 @@ have — see above). So both halves live in this package instead of in every con
158
161
 
159
162
  ```bash
160
163
  gjsify devtools shot /tmp/app.png --bus-name org.example.App # PNG on disk + its real size
164
+ gjsify devtools shot /tmp/app@2x.png --scale 2 --bus-name org.example.App # 2× device pixels
161
165
  gjsify devtools find Adw.StatusPage:error --bus-name org.example.App # → a widget path
162
166
  gjsify devtools activate /toplevel:0/child:3 --bus-name org.example.App
163
167
  gjsify devtools help # every operation and flag
@@ -192,6 +196,25 @@ shot.width; // 1100 — read out of the PNG, NOT the size asked for
192
196
  The `./shot` entry point is deliberately GTK-free: the client side of a screenshot needs Gio and
193
197
  GLib only, so a headless client does not drag this package's app-side GTK adapter in with it.
194
198
 
199
+ For a 2× shot, the `source` lambda calls `ScreenshotScaled` instead — `captureShot` itself has
200
+ nothing to say about the scale, because the scope is the only thing it passes on:
201
+
202
+ ```ts
203
+ await captureShot(
204
+ async (scope) =>
205
+ client.control(instance, 'ScreenshotScaled', tupleSD(scope, 2), '(ay)'),
206
+ '/tmp/app@2x.png',
207
+ );
208
+ ```
209
+
210
+ **Keep the default on `Screenshot`.** The app and the caller are published separately, so a
211
+ caller that reaches for `ScreenshotScaled` unconditionally fails with `UnknownMethod` against
212
+ every app on `@gjsify/devtools < 0.64` — for a capture the old app could have served. That is
213
+ also why the override is a second method rather than a third argument: an added in-arg fails
214
+ `InvalidArgs`, which a caller cannot tell from a request it got wrong, whereas an unknown NAME is
215
+ detectable and falls back cleanly. `gjsify devtools shot` and the MCP `screenshot` tool both
216
+ switch methods only when a scale is actually asked for.
217
+
195
218
  Two invariants ride along, each paid for with a measured incident:
196
219
 
197
220
  - **Empty bytes are a FAILURE, not a picture.** `Screenshot` answers `ay[0]` when there is no active
@@ -253,7 +276,7 @@ literals, it does not claim a rendered string is clean.
253
276
  - `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).
254
277
  - `DevtoolsExtension`, `InstallDevtoolsOptions` — the extension + options types.
255
278
  - `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.
256
- - `captureWidgetPng` (GSK screenshot), `buildVariant` / `variantKindFor` / `VariantKind` (GVariant), `activateAction` / `changeActionState` / `describeActions` (GAction bridge).
279
+ - `captureWidget(widget, options?)` → `CaptureResult` and `captureWidgetPng(widget, options?)` → bytes-or-`null` (GSK screenshot); `CaptureOptions.scale` is device pixels per logical pixel and `defaultCaptureScale(widget)` is what it defaults to — the widget's own `get_scale_factor()`, clamped to a positive number. `buildVariant` / `variantKindFor` / `VariantKind` (GVariant), `activateAction` / `changeActionState` / `describeActions` (GAction bridge).
257
280
  - widget-tree helpers (`dumpTree`, `getWidgetProperty`, `listToplevels`, `resolveWidgetPath`, …), `dumpCss` / `swapCss` / `removeCss`, `dumpGSettings`, `buildDevtoolsIfaceXml`.
258
281
  - **client-side, from the GTK-free `./shot` entry point**: `captureShot(source, path, scope?, options?)` → `ShotResult` (with `width`/`height` read from the PNG) or `CaptureShotError`; `pngSize(bytes)` → `{ width, height }` or `null` for bytes that are not a PNG.
259
282
  - markup-trap helpers (root entry point): `hasRawPangoMarkup(text)`, `rawPangoMarkupIn(text)`, `isPangoMarkupSink(class, property)`, `PANGO_MARKUP_SINKS`.
@@ -7,6 +7,11 @@ import"./_virtual/_rolldown/runtime.js";import{DEVTOOLS_INTERFACE as e}from"@gjs
7
7
  <arg type="s" direction="in" name="scope"/>
8
8
  <arg type="ay" direction="out" name="png_bytes"/>
9
9
  </method>
10
+ <method name="ScreenshotScaled">
11
+ <arg type="s" direction="in" name="scope"/>
12
+ <arg type="d" direction="in" name="scale"/>
13
+ <arg type="ay" direction="out" name="png_bytes"/>
14
+ </method>
10
15
  <method name="ListActions">
11
16
  <arg type="s" direction="out" name="actions_json"/>
12
17
  </method>
@@ -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{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};
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={},n=12,r=50){let i=null;for(let a=0;a<n;a++){let n=s(e,t);if(n.png)return{png:n.png,blocker:null};i=n.blocker,await frameDelay(r)}let a=s(e,t);return{png:a.png,blocker:a.png?null:a.blocker??i}}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 ScreenshotScaledAsync(e,t){try{let n=typeof e?.[0]==`string`?e[0]:``,r=typeof e?.[1]==`number`?e[1]:0,i=await this._captureScopePng(n,r>0?{scale:r}:{});t.return_value(new y.Variant(`(ay)`,[i]))}catch(e){t.return_dbus_error(`org.gjsify.Devtools.Error.Screenshot`,e instanceof Error?e.message:String(e))}}async _captureScopePng(e,t={}){let n=this._resolveRootWidget(e);if(!n){if(isActiveWindowScope(e))return new Uint8Array;throw Error(S(`not-found`,`no widget at '${e}'`))}let r=s(n.widget,t);if(r.png)return r.png;let i=n.widget.get_root();i instanceof b.Window&&i.present();let a=await captureWidgetWhenRenderable(n.widget,t);return a.png?a.png:(console.warn(`[devtools] Screenshot('${e}') has no image: ${a.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{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";import{CaptureShotError as k,captureShot as A,pngSize as j}from"./shot.js";import{PANGO_MARKUP_SINKS as M,hasRawPangoMarkup as N,isPangoMarkupSink as P,rawPangoMarkupIn as F}from"./pango.js";export*from"@gjsify/devtools-protocol";export{k as CaptureShotError,l as DevtoolsPeerServerError,T as DevtoolsService,M as PANGO_MARKUP_SINKS,n as activateAction,c as buildDevtoolsIfaceXml,e as buildVariant,g as buildWidgetPath,A as captureShot,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,N as hasRawPangoMarkup,D as installDevtools,P as isPangoMarkupSink,y as listToplevels,b as parseWidgetPath,x as pathOfWidget,j as pngSize,F as rawPangoMarkupIn,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};
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,defaultCaptureScale as h}from"./screenshot.js";import{dumpGSettings as g}from"./gsettings.js";import{buildWidgetPath as _,dumpTree as v,getWidgetProperty as y,listToplevels as b,parseWidgetPath as x,pathOfWidget as S,resolveWidgetPath as C,widgetIsA as w,widgetType as T}from"./widget-tree.js";import{DevtoolsService as E}from"./devtools-service.js";import{chooseDevtoolsTransport as D,installDevtools as O,uninstallDevtools as k}from"./install.js";import{CaptureShotError as A,captureShot as j,pngSize as M}from"./shot.js";import{PANGO_MARKUP_SINKS as N,hasRawPangoMarkup as P,isPangoMarkupSink as F,rawPangoMarkupIn as I}from"./pango.js";export*from"@gjsify/devtools-protocol";export{A as CaptureShotError,l as DevtoolsPeerServerError,E as DevtoolsService,N as PANGO_MARKUP_SINKS,n as activateAction,c as buildDevtoolsIfaceXml,e as buildVariant,_ as buildWidgetPath,j as captureShot,p as captureWidget,m as captureWidgetPng,r as changeActionState,D as chooseDevtoolsTransport,h as defaultCaptureScale,i as describeActions,a as dumpCss,g as dumpGSettings,v as dumpTree,y as getWidgetProperty,P as hasRawPangoMarkup,O as installDevtools,F as isPangoMarkupSink,b as listToplevels,x as parseWidgetPath,S as pathOfWidget,M as pngSize,I as rawPangoMarkupIn,o as removeCss,u as removeDevtoolsAddressFile,C as resolveWidgetPath,d as startDevtoolsPeerServer,s as swapCss,k as uninstallDevtools,t as variantKindFor,w as widgetIsA,T as widgetType,f as writeDevtoolsAddressFile};
@@ -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 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};
1
+ import"./_virtual/_rolldown/runtime.js";import e from"gi://Gtk?version=4.0";import t from"gi://Graphene?version=1.0";function defaultCaptureScale(e){let t=e.get_scale_factor?.();return typeof t==`number`&&t>0?t:1}function captureWidget(n,r={}){let i=n.get_native()?.get_renderer();if(!i)return{png:null,blocker:`no-renderer`};let a=n.get_width(),o=n.get_height();if(a<=0||o<=0)return{png:null,blocker:`zero-size`};let s=r.scale&&r.scale>0?r.scale:defaultCaptureScale(n),c=e.WidgetPaintable.new(n),l=e.Snapshot.new();s!==1&&l.scale(s,s),c.snapshot(l,a,o);let u=l.to_node();if(!u)return{png:null,blocker:`empty-snapshot`};let d=new t.Rect;d.init(0,0,a*s,o*s);let f=i.render_texture(u,d).save_to_png_bytes().get_data();return f?{png:new Uint8Array(f)}:{png:null,blocker:`empty-png`}}function captureWidgetPng(e,t={}){return captureWidget(e,t).png}export{captureWidget,captureWidgetPng,defaultCaptureScale};
@@ -73,8 +73,27 @@ export declare class DevtoolsService {
73
73
  * implementation returned an incorrect value type`), while a manual reply was
74
74
  * unaffected. That no longer reproduces on gjs 1.88.1 — a plain `async Screenshot()`
75
75
  * returning `ay` marshals correctly — so this can collapse back into one.
76
+ *
77
+ * The scale is the WIDGET's own `get_scale_factor()` and is not on this signature on
78
+ * purpose — see {@link ScreenshotScaledAsync} for why the override is a second method
79
+ * rather than a third argument here.
76
80
  */
77
81
  ScreenshotAsync(params: unknown[], invocation: Gio.DBusMethodInvocation): Promise<void>;
82
+ /**
83
+ * `ScreenshotScaled(scope, scale) -> ay` — the same capture at an EXPLICIT device-pixel
84
+ * scale. `scale <= 0` means "whatever the widget's own `get_scale_factor()` says", which
85
+ * is exactly what `Screenshot` does, so the two methods differ in one thing only.
86
+ *
87
+ * WHY A SECOND METHOD AND NOT A THIRD ARGUMENT ON `Screenshot`. The two ends of this
88
+ * wire are published separately — `@gjsify/devtools` is in the app, `@gjsify/devtools-mcp`
89
+ * and the `gjsify devtools` CLI are in the caller's toolchain — so every mixed-version
90
+ * pair is a pair that really occurs. Adding an in-arg makes a new CLI's `(sd)` call fail
91
+ * `InvalidArgs` against an app on an older devtools, for a request the old app could
92
+ * have served; a new NAME fails `UnknownMethod`, which is a thing a caller can test for
93
+ * and fall back from, and it leaves `Screenshot(s) -> ay` byte-identical for the
94
+ * `gdbus` lines in this package's README and in consumers' scripts.
95
+ */
96
+ ScreenshotScaledAsync(params: unknown[], invocation: Gio.DBusMethodInvocation): Promise<void>;
78
97
  /**
79
98
  * Capture `scope` to PNG, warming up across frames: a just-launched or mid-layout
80
99
  * window yields a zero-size GSK frame, so the first capture can be empty. Empty
@@ -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 { captureWidget, captureWidgetPng, type CaptureBlocker, type CaptureResult } from './screenshot.js';
7
+ export { captureWidget, captureWidgetPng, defaultCaptureScale, type CaptureBlocker, type CaptureOptions, type CaptureResult, } from './screenshot.js';
8
8
  export { CaptureShotError, captureShot, pngSize } from './shot.js';
9
9
  export type { CaptureShotOptions, PngSize, PngSource, ShotFailure, ShotResult } from './shot.js';
10
10
  export { buildVariant, variantKindFor } from './gvariant.js';
@@ -29,6 +29,25 @@ export type CaptureResult = {
29
29
  readonly png: null;
30
30
  readonly blocker: CaptureBlocker;
31
31
  };
32
+ /** Options for {@link captureWidget}. */
33
+ export interface CaptureOptions {
34
+ /**
35
+ * Device pixels per logical pixel. Defaults to the widget's own
36
+ * `get_scale_factor()`, so a window on a HiDPI monitor captures at the
37
+ * resolution it is actually drawn at.
38
+ */
39
+ readonly scale?: number;
40
+ }
41
+ /**
42
+ * The scale a capture uses when the caller names none: the widget's own
43
+ * `Gtk.Widget.get_scale_factor()`, clamped to a usable positive number.
44
+ *
45
+ * A widget handed in from a duck-typed test, or one not yet attached to a
46
+ * surface, may answer 0 or nothing at all — that is not a request for a
47
+ * zero-pixel image, it is an absent answer, and 1 is what the capture did
48
+ * before scaling existed.
49
+ */
50
+ export declare function defaultCaptureScale(widget: Gtk.Widget): number;
32
51
  /**
33
52
  * Render a GTK widget — typically the top-level window — to PNG bytes, fully
34
53
  * in-process via the GSK renderer. No external screenshot tools, no
@@ -40,7 +59,25 @@ export type CaptureResult = {
40
59
  * `gtk4paintablesink` paintable in a `Gtk.Picture` captures at 202 645 bytes for the
41
60
  * picture alone and 204 865 for its window, both with `videotestsrc` and with a real
42
61
  * HLS stream. The renderer downloads the texture like any other.
62
+ *
63
+ * THE SCALE IS A GSK TRANSFORM AND IT GENUINELY RE-RASTERISES — the question every
64
+ * reader of this function asks, measured rather than assumed. `Gtk.Snapshot.scale()`
65
+ * before `paintable.snapshot()` makes the whole subtree draw into a viewport `scale`
66
+ * times larger, and text and symbolic icons come out re-rendered at that resolution,
67
+ * not magnified: Laplacian variance against a bicubic 2× upscale of the same 1×
68
+ * capture is 5–6× for a whole window, 7.35× for a symbolic icon and 7.04× for a
69
+ * full-colour themed icon. The control in the same probe is a RASTER texture at its
70
+ * native size, which scores 0.42× — it cannot gain detail it never had, and its being
71
+ * the only thing that does not prove the metric reads rasterisation rather than
72
+ * magnification. So an app's own PNG assets stay as sharp as the file they came from;
73
+ * everything GTK draws gets sharper.
74
+ *
75
+ * THE MONITOR'S SCALE DOES NOT REACH THIS CAPTURE. Only the transform does: the same
76
+ * window captured under a compositor at scale 1, 1.25 and 2 produces byte-identical
77
+ * PNGs (md5 `1294b43d…` at every monitor scale, `916c5a02…` once `scale: 2` is passed).
78
+ * This is why a HiDPI capture never happened by itself on a HiDPI screen, and why a CI
79
+ * runner under Xvfb at scale 1 can still prove the 2× path exactly.
43
80
  */
44
- export declare function captureWidget(widget: Gtk.Widget): CaptureResult;
81
+ export declare function captureWidget(widget: Gtk.Widget, options?: CaptureOptions): CaptureResult;
45
82
  /** The bytes alone, for callers that have nothing to do with the reason. */
46
- export declare function captureWidgetPng(widget: Gtk.Widget): Uint8Array | null;
83
+ export declare function captureWidgetPng(widget: Gtk.Widget, options?: CaptureOptions): Uint8Array | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/devtools",
3
- "version": "0.63.0",
3
+ "version": "0.63.1-next.236",
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",
@@ -49,12 +49,12 @@
49
49
  "@girs/gobject-2.0": "5.4.0",
50
50
  "@girs/graphene-1.0": "5.4.0",
51
51
  "@girs/gtk-4.0": "5.4.0",
52
- "@gjsify/devtools-protocol": "^0.63.0"
52
+ "@gjsify/devtools-protocol": "0.63.1-next.236"
53
53
  },
54
54
  "devDependencies": {
55
- "@gjsify/cli": "^0.63.0",
55
+ "@gjsify/cli": "0.63.1-next.236",
56
56
  "@gjsify/node-gi": "file:../../node-gi/node-gi",
57
- "@gjsify/unit": "^0.63.0",
57
+ "@gjsify/unit": "0.63.1-next.236",
58
58
  "@types/node": "^25.9.2",
59
59
  "typescript": "^6.0.3"
60
60
  },
@@ -81,5 +81,6 @@
81
81
  "bugs": {
82
82
  "url": "https://github.com/gjsify/gjsify/issues"
83
83
  },
84
- "homepage": "https://github.com/gjsify/gjsify/tree/main/packages/framework/devtools#readme"
84
+ "homepage": "https://github.com/gjsify/gjsify/tree/main/packages/framework/devtools#readme",
85
+ "gitHead": "d4ae412daabae9cb89f4fa11ee7279a308c9e4be"
85
86
  }