@gtkx/vitest 1.6.0 → 2.0.0-beta.10
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 +47 -44
- package/dist/headless-config.d.ts +7 -0
- package/dist/headless-config.d.ts.map +1 -0
- package/dist/headless-config.js +44 -0
- package/dist/headless-config.js.map +1 -0
- package/dist/headless-display.d.ts.map +1 -1
- package/dist/headless-display.js +134 -73
- package/dist/headless-display.js.map +1 -1
- package/dist/headless.d.ts +3 -0
- package/dist/headless.d.ts.map +1 -0
- package/dist/headless.js +3 -0
- package/dist/headless.js.map +1 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +33 -18
- package/dist/index.js.map +1 -1
- package/dist/reap-headless-displays.d.ts +10 -0
- package/dist/reap-headless-displays.d.ts.map +1 -0
- package/dist/reap-headless-displays.js +211 -0
- package/dist/reap-headless-displays.js.map +1 -0
- package/dist/worker-preload.js +9 -5
- package/dist/worker-preload.js.map +1 -1
- package/package.json +18 -8
- package/src/headless-config.ts +61 -0
- package/src/headless-display.ts +195 -90
- package/src/headless.ts +14 -0
- package/src/index.ts +20 -5
- package/src/reap-headless-displays.ts +309 -0
- package/src/worker-preload.ts +10 -5
package/README.md
CHANGED
|
@@ -5,15 +5,15 @@
|
|
|
5
5
|
<h1 align="center">GTKX</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
The React framework for Linux
|
|
9
|
-
Write native
|
|
10
|
-
Real
|
|
8
|
+
The React framework for Linux.<br />
|
|
9
|
+
Write native GNOME applications with React and TypeScript.
|
|
10
|
+
Real Adwaita and GTK4 widgets with standard web tooling.
|
|
11
11
|
</p>
|
|
12
12
|
|
|
13
13
|
<p align="center">
|
|
14
14
|
<a href="https://www.npmjs.com/package/create-gtkx"><img src="https://img.shields.io/npm/v/create-gtkx?color=cb3837&logo=npm&label=create-gtkx" alt="npm version" /></a>
|
|
15
15
|
<a href="https://www.npmjs.com/package/create-gtkx"><img src="https://img.shields.io/npm/dm/create-gtkx?color=cb3837&logo=npm&label=downloads" alt="npm downloads" /></a>
|
|
16
|
-
<img src="https://img.shields.io/badge/node-%E2%89%
|
|
16
|
+
<img src="https://img.shields.io/badge/node-%E2%89%A526.7-339933?logo=node.js&logoColor=white" alt="Node >= 26.7" />
|
|
17
17
|
<a href="https://github.com/gtkx-org/gtkx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MPL--2.0-blue.svg" alt="License: MPL-2.0" /></a>
|
|
18
18
|
<a href="https://github.com/gtkx-org/gtkx/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/gtkx-org/gtkx/ci.yml?branch=main&logo=github&label=CI" alt="CI status" /></a>
|
|
19
19
|
<img src="https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white" alt="TypeScript" />
|
|
@@ -38,11 +38,12 @@
|
|
|
38
38
|
|
|
39
39
|
## Demo
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
This app starts with an Adwaita application shell, renders native GTK4 widgets inside it, and uses ordinary React hooks and events:
|
|
42
42
|
|
|
43
43
|
```tsx
|
|
44
44
|
import * as Gtk from "@gtkx/gi/gtk";
|
|
45
|
-
import {
|
|
45
|
+
import { AdwApplication, AdwApplicationWindow, AdwHeaderBar, AdwToolbarView } from "@gtkx/jsx/adw";
|
|
46
|
+
import { GtkBox, GtkButton, GtkLabel } from "@gtkx/jsx/gtk";
|
|
46
47
|
import { createRoot, quit } from "@gtkx/react";
|
|
47
48
|
import { useState } from "react";
|
|
48
49
|
|
|
@@ -50,38 +51,40 @@ const Counter = () => {
|
|
|
50
51
|
const [count, setCount] = useState(0);
|
|
51
52
|
|
|
52
53
|
return (
|
|
53
|
-
<
|
|
54
|
+
<AdwApplicationWindow
|
|
54
55
|
title="Hello GTKX"
|
|
55
56
|
defaultWidth={400}
|
|
56
57
|
defaultHeight={300}
|
|
57
58
|
onCloseRequest={quit}
|
|
58
59
|
>
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
60
|
+
<AdwToolbarView topBar={<AdwHeaderBar />}>
|
|
61
|
+
<GtkBox
|
|
62
|
+
orientation={Gtk.Orientation.VERTICAL}
|
|
63
|
+
spacing={20}
|
|
64
|
+
marginTop={40}
|
|
65
|
+
marginBottom={40}
|
|
66
|
+
marginStart={40}
|
|
67
|
+
marginEnd={40}
|
|
68
|
+
valign={Gtk.Align.CENTER}
|
|
69
|
+
halign={Gtk.Align.CENTER}
|
|
70
|
+
>
|
|
71
|
+
<GtkLabel cssClasses={["title-1"]}>Welcome to GTKX!</GtkLabel>
|
|
72
|
+
<GtkLabel cssClasses={["title-2"]}>{`Count: ${String(count)}`}</GtkLabel>
|
|
73
|
+
<GtkButton
|
|
74
|
+
label="Increment"
|
|
75
|
+
onClicked={() => setCount((c) => c + 1)}
|
|
76
|
+
cssClasses={["suggested-action", "pill"]}
|
|
77
|
+
/>
|
|
78
|
+
</GtkBox>
|
|
79
|
+
</AdwToolbarView>
|
|
80
|
+
</AdwApplicationWindow>
|
|
78
81
|
);
|
|
79
82
|
};
|
|
80
83
|
|
|
81
84
|
const App = () => (
|
|
82
|
-
<
|
|
85
|
+
<AdwApplication>
|
|
83
86
|
<Counter />
|
|
84
|
-
</
|
|
87
|
+
</AdwApplication>
|
|
85
88
|
);
|
|
86
89
|
|
|
87
90
|
createRoot().render(<App />);
|
|
@@ -91,9 +94,9 @@ This is the [`hello-world`](https://github.com/gtkx-org/gtkx/tree/main/examples/
|
|
|
91
94
|
|
|
92
95
|
## Why GTKX
|
|
93
96
|
|
|
94
|
-
###
|
|
97
|
+
### Adwaita-first application development
|
|
95
98
|
|
|
96
|
-
|
|
99
|
+
GTKX uses Adwaita as the foundation for applications, windows, navigation, dialogs, and adaptive layouts, with the complete GTK4 toolkit underneath. It adds a declarative React layer and an integrated TypeScript toolchain to the GNOME platform:
|
|
97
100
|
|
|
98
101
|
- a React reconciler that exposes every GObject as a JSX element,
|
|
99
102
|
- a CLI for scaffolding, development, and production builds,
|
|
@@ -102,29 +105,29 @@ GTK4 is mature, and GtkBuilder XML can lay out a static interface, but nothing r
|
|
|
102
105
|
- a Testing Library-style API for querying and driving your widgets in tests,
|
|
103
106
|
- and a Model Context Protocol (MCP) server that exposes your live app to AI agents.
|
|
104
107
|
|
|
105
|
-
###
|
|
108
|
+
### Native GNOME widgets, without a compatibility layer
|
|
106
109
|
|
|
107
|
-
React Native and similar frameworks hide the native toolkit so one API can run everywhere. GTKX
|
|
110
|
+
React Native and similar frameworks hide the native toolkit so one API can run everywhere. GTKX renders real Adwaita and GTK4 widgets and exposes any other GObject-Introspection library on your system. It is GNOME-native and Linux-only by design.
|
|
108
111
|
|
|
109
112
|
### Why Node.js, and why generated bindings
|
|
110
113
|
|
|
111
114
|
GTKX runs on Node.js, which puts native modules, the npm ecosystem, and the tooling built for Node.js APIs within reach. GJS is GNOME's own runtime, built on SpiderMonkey rather than V8; node-gtk runs on Node.js but is lightly maintained, on the older nan/V8 ABI rather than N-API, and still centered on GTK3. The [Why GTKX guide](https://gtkx.dev/guide/why-gtkx) covers what running on Node.js means day to day.
|
|
112
115
|
|
|
113
|
-
GTKX generates the TypeScript types and
|
|
116
|
+
GTKX generates the TypeScript types and native FFI calls from the same GObject-Introspection data, so the types cannot drift from the calls they back. `Adw-1` is the sole default GIR root; its GIR include pulls in `Gtk-4.0`, so codegen covers both complete API surfaces. Neither belongs in a GTKX 2 `libraries` list.
|
|
114
117
|
|
|
115
|
-
At runtime, the native Rust core calls straight into the system
|
|
118
|
+
At runtime, the native Rust core calls straight into the system Adwaita, GTK4, and GLib libraries through libffi, without loading libgirepository at all.
|
|
116
119
|
|
|
117
120
|
## Quick start
|
|
118
121
|
|
|
119
|
-
GTKX is Linux-only and needs Node.js
|
|
122
|
+
GTKX is Linux-only and needs Node.js 26.7 or later. See [Requirements](#requirements).
|
|
120
123
|
|
|
121
124
|
Scaffold a new app with the `create-gtkx` initializer:
|
|
122
125
|
|
|
123
126
|
```sh
|
|
124
|
-
npm create gtkx
|
|
127
|
+
npm create gtkx@beta
|
|
125
128
|
```
|
|
126
129
|
|
|
127
|
-
The same command works with other package managers: `pnpm create gtkx` or `yarn create gtkx`.
|
|
130
|
+
The same command works with other package managers: `pnpm create gtkx@beta` or `yarn create gtkx@beta`.
|
|
128
131
|
|
|
129
132
|
Then run your new app:
|
|
130
133
|
|
|
@@ -145,8 +148,8 @@ The documentation at **[gtkx.dev](https://gtkx.dev)** includes a step-by-step tu
|
|
|
145
148
|
|
|
146
149
|
GTKX is Linux-only. You need:
|
|
147
150
|
|
|
148
|
-
- Linux with the
|
|
149
|
-
- Node.js
|
|
151
|
+
- Linux with the Adwaita (1.8 or later), GTK4 (4.20 or later), and GLib development libraries
|
|
152
|
+
- Node.js 26.7 or later
|
|
150
153
|
|
|
151
154
|
The `@gtkx/native` addon ships prebuilt for x64 and arm64 glibc Linux; other targets need to build it from the GTKX repository, which requires a Rust toolchain.
|
|
152
155
|
|
|
@@ -154,10 +157,10 @@ The `@gtkx/native` addon ships prebuilt for x64 and arm64 glibc Linux; other tar
|
|
|
154
157
|
|
|
155
158
|
Explore the [example apps](https://github.com/gtkx-org/gtkx/tree/main/examples):
|
|
156
159
|
|
|
157
|
-
- [`hello-world`](https://github.com/gtkx-org/gtkx/tree/main/examples/hello-world): the counter above.
|
|
158
|
-
- [`gtk-demo`](https://github.com/gtkx-org/gtkx/tree/main/examples/gtk-demo):
|
|
159
|
-
- [`browser`](https://github.com/gtkx-org/gtkx/tree/main/examples/browser):
|
|
160
|
-
- [`animations`](https://github.com/gtkx-org/gtkx/tree/main/examples/animations):
|
|
160
|
+
- [`hello-world`](https://github.com/gtkx-org/gtkx/tree/main/examples/hello-world): the Adwaita counter above.
|
|
161
|
+
- [`gtk-demo`](https://github.com/gtkx-org/gtkx/tree/main/examples/gtk-demo): the official GTK4 widget showcase in an Adwaita application shell, covering lists, dialogs, gestures, CSS, and OpenGL.
|
|
162
|
+
- [`browser`](https://github.com/gtkx-org/gtkx/tree/main/examples/browser): an Adwaita browser built around `WebKitWebView`.
|
|
163
|
+
- [`animations`](https://github.com/gtkx-org/gtkx/tree/main/examples/animations): an Adwaita showcase for `@gtkx/animated`, with React Spring animations driven by the GTK frame clock.
|
|
161
164
|
- [`navigation`](https://github.com/gtkx-org/gtkx/tree/main/examples/navigation): a tour of `@gtkx/navigation`, React Navigation's stack, tab, and drawer navigators rendered with libadwaita.
|
|
162
165
|
- [`tutorial`](https://github.com/gtkx-org/gtkx/tree/main/examples/tutorial): the Tasks app the documentation builds.
|
|
163
166
|
|
|
@@ -167,7 +170,7 @@ GTKX is stable and ready for production use.
|
|
|
167
170
|
|
|
168
171
|
## Contributing
|
|
169
172
|
|
|
170
|
-
Contributions are welcome. See [CONTRIBUTING.md](https://github.com/gtkx-org/gtkx/blob/main/CONTRIBUTING.md), the [Code of Conduct](https://github.com/gtkx-org/gtkx/blob/main/CODE_OF_CONDUCT.md), and the [security policy](https://github.com/gtkx-org/gtkx/blob/main/SECURITY.md). Building the repo needs Node.js
|
|
173
|
+
Contributions are welcome. See [CONTRIBUTING.md](https://github.com/gtkx-org/gtkx/blob/main/CONTRIBUTING.md), the [Code of Conduct](https://github.com/gtkx-org/gtkx/blob/main/CODE_OF_CONDUCT.md), and the [security policy](https://github.com/gtkx-org/gtkx/blob/main/SECURITY.md). Building the repo needs Node.js 26.7 or later, pnpm, and a Rust toolchain.
|
|
171
174
|
|
|
172
175
|
## License
|
|
173
176
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
declare const HEADLESS_RUNTIME_MARKER = ".gtkx-headless-runtime";
|
|
2
|
+
declare const createSwayConfig: (width: string, height: string) => string;
|
|
3
|
+
declare const createBusConfig: (busSocketPath: string) => string;
|
|
4
|
+
declare const createHeadlessRuntimeMarker: (runtimeDir: string) => string;
|
|
5
|
+
declare const isSwayConfig: (value: string) => boolean;
|
|
6
|
+
export { createBusConfig, createHeadlessRuntimeMarker, createSwayConfig, HEADLESS_RUNTIME_MARKER, isSwayConfig, };
|
|
7
|
+
//# sourceMappingURL=headless-config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"headless-config.d.ts","sourceRoot":"","sources":["../src/headless-config.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,uBAAuB,2BAA2B,CAAC;AAazD,QAAA,MAAM,gBAAgB,GAAI,OAAO,MAAM,EAAE,QAAQ,MAAM,KAAG,MAU1C,CAAC;AAEjB,QAAA,MAAM,eAAe,GAAI,eAAe,MAAM,KAAG,MAajC,CAAC;AAEjB,QAAA,MAAM,2BAA2B,GAAI,YAAY,MAAM,KAAG,MACc,CAAC;AAEzE,QAAA,MAAM,YAAY,GAAI,OAAO,MAAM,KAAG,OAKrC,CAAC;AAEF,OAAO,EACH,eAAe,EACf,2BAA2B,EAC3B,gBAAgB,EAChB,uBAAuB,EACvB,YAAY,GACf,CAAC"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const BUS_CONFIG_DOCTYPE = '<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN" ' +
|
|
2
|
+
'"https://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">';
|
|
3
|
+
const HEADLESS_RUNTIME_MARKER = ".gtkx-headless-runtime";
|
|
4
|
+
const SWAY_CONFIG_LINES = [
|
|
5
|
+
/^xwayland disable$/,
|
|
6
|
+
/^default_border none$/,
|
|
7
|
+
/^default_floating_border none$/,
|
|
8
|
+
/^output HEADLESS-1 resolution [1-9]\d*x[1-9]\d*$/,
|
|
9
|
+
/^output HEADLESS-1 bg #000000 solid_color$/,
|
|
10
|
+
/^for_window \[app_id="\.\*"\] floating enable, border none$/,
|
|
11
|
+
/^for_window \[title="\.\*"\] floating enable, border none$/,
|
|
12
|
+
/^$/,
|
|
13
|
+
];
|
|
14
|
+
const createSwayConfig = (width, height) => [
|
|
15
|
+
"xwayland disable",
|
|
16
|
+
"default_border none",
|
|
17
|
+
"default_floating_border none",
|
|
18
|
+
`output HEADLESS-1 resolution ${width}x${height}`,
|
|
19
|
+
"output HEADLESS-1 bg #000000 solid_color",
|
|
20
|
+
'for_window [app_id=".*"] floating enable, border none',
|
|
21
|
+
'for_window [title=".*"] floating enable, border none',
|
|
22
|
+
"",
|
|
23
|
+
].join("\n");
|
|
24
|
+
const createBusConfig = (busSocketPath) => [
|
|
25
|
+
BUS_CONFIG_DOCTYPE,
|
|
26
|
+
"<busconfig>",
|
|
27
|
+
" <type>session</type>",
|
|
28
|
+
` <listen>unix:path=${busSocketPath}</listen>`,
|
|
29
|
+
" <auth>EXTERNAL</auth>",
|
|
30
|
+
' <policy context="default">',
|
|
31
|
+
' <allow send_destination="*" eavesdrop="true"/>',
|
|
32
|
+
' <allow eavesdrop="true"/>',
|
|
33
|
+
' <allow own="*"/>',
|
|
34
|
+
" </policy>",
|
|
35
|
+
"</busconfig>",
|
|
36
|
+
].join("\n");
|
|
37
|
+
const createHeadlessRuntimeMarker = (runtimeDir) => ["gtkx-headless-runtime-v1", `runtime=${runtimeDir}`, ""].join("\n");
|
|
38
|
+
const isSwayConfig = (value) => {
|
|
39
|
+
const lines = value.split("\n");
|
|
40
|
+
return lines.length === SWAY_CONFIG_LINES.length &&
|
|
41
|
+
SWAY_CONFIG_LINES.every((pattern, index) => pattern.test(lines[index] ?? "invalid"));
|
|
42
|
+
};
|
|
43
|
+
export { createBusConfig, createHeadlessRuntimeMarker, createSwayConfig, HEADLESS_RUNTIME_MARKER, isSwayConfig, };
|
|
44
|
+
//# sourceMappingURL=headless-config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"headless-config.js","sourceRoot":"","sources":["../src/headless-config.ts"],"names":[],"mappings":"AAAA,MAAM,kBAAkB,GACpB,mFAAmF;IACnF,iEAAiE,CAAC;AAEtE,MAAM,uBAAuB,GAAG,wBAAwB,CAAC;AAEzD,MAAM,iBAAiB,GAAG;IACtB,oBAAoB;IACpB,uBAAuB;IACvB,gCAAgC;IAChC,kDAAkD;IAClD,4CAA4C;IAC5C,6DAA6D;IAC7D,4DAA4D;IAC5D,IAAI;CACP,CAAC;AAEF,MAAM,gBAAgB,GAAG,CAAC,KAAa,EAAE,MAAc,EAAU,EAAE,CAC/D;IACI,kBAAkB;IAClB,qBAAqB;IACrB,8BAA8B;IAC9B,gCAAgC,KAAK,IAAI,MAAM,EAAE;IACjD,0CAA0C;IAC1C,uDAAuD;IACvD,sDAAsD;IACtD,EAAE;CACL,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAEjB,MAAM,eAAe,GAAG,CAAC,aAAqB,EAAU,EAAE,CACtD;IACI,kBAAkB;IAClB,aAAa;IACb,wBAAwB;IACxB,uBAAuB,aAAa,WAAW;IAC/C,yBAAyB;IACzB,8BAA8B;IAC9B,oDAAoD;IACpD,+BAA+B;IAC/B,sBAAsB;IACtB,aAAa;IACb,cAAc;CACjB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAEjB,MAAM,2BAA2B,GAAG,CAAC,UAAkB,EAAU,EAAE,CAC/D,CAAC,0BAA0B,EAAE,WAAW,UAAU,EAAE,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAEzE,MAAM,YAAY,GAAG,CAAC,KAAa,EAAW,EAAE;IAC5C,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAEhC,OAAO,KAAK,CAAC,MAAM,KAAK,iBAAiB,CAAC,MAAM;QAC5C,iBAAiB,CAAC,KAAK,CAAC,CAAC,OAAO,EAAE,KAAK,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,SAAS,CAAC,CAAC,CAAC;AAC7F,CAAC,CAAC;AAEF,OAAO,EACH,eAAe,EACf,2BAA2B,EAC3B,gBAAgB,EAChB,uBAAuB,EACvB,YAAY,GACf,CAAC","sourcesContent":["const BUS_CONFIG_DOCTYPE =\n '<!DOCTYPE busconfig PUBLIC \"-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN\" ' +\n '\"https://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd\">';\n\nconst HEADLESS_RUNTIME_MARKER = \".gtkx-headless-runtime\";\n\nconst SWAY_CONFIG_LINES = [\n /^xwayland disable$/,\n /^default_border none$/,\n /^default_floating_border none$/,\n /^output HEADLESS-1 resolution [1-9]\\d*x[1-9]\\d*$/,\n /^output HEADLESS-1 bg #000000 solid_color$/,\n /^for_window \\[app_id=\"\\.\\*\"\\] floating enable, border none$/,\n /^for_window \\[title=\"\\.\\*\"\\] floating enable, border none$/,\n /^$/,\n];\n\nconst createSwayConfig = (width: string, height: string): string =>\n [\n \"xwayland disable\",\n \"default_border none\",\n \"default_floating_border none\",\n `output HEADLESS-1 resolution ${width}x${height}`,\n \"output HEADLESS-1 bg #000000 solid_color\",\n 'for_window [app_id=\".*\"] floating enable, border none',\n 'for_window [title=\".*\"] floating enable, border none',\n \"\",\n ].join(\"\\n\");\n\nconst createBusConfig = (busSocketPath: string): string =>\n [\n BUS_CONFIG_DOCTYPE,\n \"<busconfig>\",\n \" <type>session</type>\",\n ` <listen>unix:path=${busSocketPath}</listen>`,\n \" <auth>EXTERNAL</auth>\",\n ' <policy context=\"default\">',\n ' <allow send_destination=\"*\" eavesdrop=\"true\"/>',\n ' <allow eavesdrop=\"true\"/>',\n ' <allow own=\"*\"/>',\n \" </policy>\",\n \"</busconfig>\",\n ].join(\"\\n\");\n\nconst createHeadlessRuntimeMarker = (runtimeDir: string): string =>\n [\"gtkx-headless-runtime-v1\", `runtime=${runtimeDir}`, \"\"].join(\"\\n\");\n\nconst isSwayConfig = (value: string): boolean => {\n const lines = value.split(\"\\n\");\n\n return lines.length === SWAY_CONFIG_LINES.length &&\n SWAY_CONFIG_LINES.every((pattern, index) => pattern.test(lines[index] ?? \"invalid\"));\n};\n\nexport {\n createBusConfig,\n createHeadlessRuntimeMarker,\n createSwayConfig,\n HEADLESS_RUNTIME_MARKER,\n isSwayConfig,\n};\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"headless-display.d.ts","sourceRoot":"","sources":["../src/headless-display.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"headless-display.d.ts","sourceRoot":"","sources":["../src/headless-display.ts"],"names":[],"mappings":"AAoBA;;GAEG;AACH,KAAK,YAAY,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEtC;;GAEG;AACH,KAAK,eAAe,GAAG;IACnB,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAC;IACb,4DAA4D;IAC5D,UAAU,EAAE,YAAY,CAAC;CAC5B,CAAC;AAoGF,QAAA,MAAM,mBAAmB;;;;;;;;;;;CAWxB,CAAC;AAEF,QAAA,MAAM,sBAAsB,GAAI,UAAU,OAAO,CAAC,eAAe,CAAC,KAAG,eAWpE,CAAC;AAqCF,QAAA,MAAM,mBAAmB,GAAI,QAAQ,eAAe,KAAG,OAAO,CAAC,eAAe,CAe7E,CAAC;AA6TF,QAAA,MAAM,oBAAoB,GAAU,SAAS,eAAe,KAAG,OAAO,CAAC,MAAM,IAAI,CAqDhF,CAAC;AAEF,OAAO,EACH,mBAAmB,EACnB,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,KAAK,YAAY,EACjB,KAAK,eAAe,GACvB,CAAC"}
|
package/dist/headless-display.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { resolveExecutable, spawnWithParentDeathSignal } from "@gtkx/utils";
|
|
1
|
+
import { isProcessAlive, resolveExecutable, spawnWithParentDeathSignal, spawnWithParentDeathSupervisor, } from "@gtkx/utils";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { chmodSync,
|
|
4
|
-
import { Socket } from "node:net";
|
|
3
|
+
import { chmodSync, closeSync, existsSync, mkdtempSync, openSync, rmSync, writeFileSync, writeSync } from "node:fs";
|
|
4
|
+
import { connect, Socket } from "node:net";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
+
import { createBusConfig, createHeadlessRuntimeMarker, createSwayConfig, HEADLESS_RUNTIME_MARKER, } from "./headless-config.js";
|
|
7
8
|
import { startNotificationService } from "./notification-service.js";
|
|
8
9
|
import { startVirtualSeat } from "./virtual-seat.js";
|
|
9
10
|
const DEFAULT_HEADLESS_SIZE = "1024x768";
|
|
10
11
|
const DEFAULT_HEADLESS_COMPOSITOR = "sway";
|
|
11
|
-
const
|
|
12
|
-
'"https://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">';
|
|
12
|
+
const HEADLESS_SIZE_PATTERN = /^[1-9]\d*x[1-9]\d*$/;
|
|
13
13
|
const hasWestonFakeSeat = createWestonFakeSeatProbe();
|
|
14
14
|
const compositorRegistry = {
|
|
15
15
|
sway: {
|
|
@@ -24,17 +24,11 @@ const compositorRegistry = {
|
|
|
24
24
|
},
|
|
25
25
|
start: (runtimeDir, width, height) => {
|
|
26
26
|
const configPath = join(runtimeDir, "sway.conf");
|
|
27
|
-
writeFileSync(configPath,
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"output HEADLESS-1 bg #000000 solid_color",
|
|
33
|
-
'for_window [app_id=".*"] floating enable, border none',
|
|
34
|
-
'for_window [title=".*"] floating enable, border none',
|
|
35
|
-
"",
|
|
36
|
-
].join("\n"));
|
|
37
|
-
return spawnWithParentDeathSignal("sway", ["-c", configPath], { stdio: ["ignore", "ignore", "pipe"] });
|
|
27
|
+
writeFileSync(configPath, createSwayConfig(width, height));
|
|
28
|
+
return spawnWithParentDeathSupervisor("sway", ["-c", configPath], {
|
|
29
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
30
|
+
cleanupDirectory: runtimeDir,
|
|
31
|
+
});
|
|
38
32
|
},
|
|
39
33
|
},
|
|
40
34
|
weston: {
|
|
@@ -48,7 +42,7 @@ const compositorRegistry = {
|
|
|
48
42
|
`--width=${width}`,
|
|
49
43
|
`--height=${height}`,
|
|
50
44
|
"--socket=wayland-0",
|
|
51
|
-
], { stdio: ["ignore", "ignore", "pipe"] }),
|
|
45
|
+
], { stdio: ["ignore", "ignore", "pipe"], cleanupDirectories: [_runtimeDir] }),
|
|
52
46
|
},
|
|
53
47
|
};
|
|
54
48
|
const STATIC_HEADLESS_ENV = {
|
|
@@ -63,10 +57,16 @@ const STATIC_HEADLESS_ENV = {
|
|
|
63
57
|
ALSOFT_DRIVERS: "null",
|
|
64
58
|
ALSOFT_LOGLEVEL: "0",
|
|
65
59
|
};
|
|
66
|
-
const resolveHeadlessOptions = (provided) =>
|
|
67
|
-
size
|
|
68
|
-
|
|
69
|
-
});
|
|
60
|
+
const resolveHeadlessOptions = (provided) => {
|
|
61
|
+
const size = provided.size ?? DEFAULT_HEADLESS_SIZE;
|
|
62
|
+
if (!HEADLESS_SIZE_PATTERN.test(size)) {
|
|
63
|
+
throw new Error(`Invalid headless display size: ${size}`);
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
size,
|
|
67
|
+
compositor: provided.compositor ?? DEFAULT_HEADLESS_COMPOSITOR,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
70
|
const applyEnv = (snapshot, values) => {
|
|
71
71
|
for (const [name, value] of Object.entries(values)) {
|
|
72
72
|
if (!Object.hasOwn(snapshot, name)) {
|
|
@@ -119,21 +119,8 @@ const startCompositor = (runtimeDir, options, env) => {
|
|
|
119
119
|
};
|
|
120
120
|
};
|
|
121
121
|
const noVirtualSeat = () => undefined;
|
|
122
|
-
const attachVirtualSeat = (compositor, socketPath) => compositor.requiresVirtualSeat ? startVirtualSeat(socketPath) : Promise.resolve(noVirtualSeat);
|
|
123
122
|
const writeBusConfig = (busConfigPath, busSocketPath) => {
|
|
124
|
-
writeFileSync(busConfigPath,
|
|
125
|
-
BUS_CONFIG_DOCTYPE,
|
|
126
|
-
"<busconfig>",
|
|
127
|
-
" <type>session</type>",
|
|
128
|
-
` <listen>unix:path=${busSocketPath}</listen>`,
|
|
129
|
-
" <auth>EXTERNAL</auth>",
|
|
130
|
-
' <policy context="default">',
|
|
131
|
-
' <allow send_destination="*" eavesdrop="true"/>',
|
|
132
|
-
' <allow eavesdrop="true"/>',
|
|
133
|
-
' <allow own="*"/>',
|
|
134
|
-
" </policy>",
|
|
135
|
-
"</busconfig>",
|
|
136
|
-
].join("\n"));
|
|
123
|
+
writeFileSync(busConfigPath, createBusConfig(busSocketPath));
|
|
137
124
|
};
|
|
138
125
|
const exitedMessage = (label, path, code, signal) => `${label} exited (code ${String(code)}, signal ${signal ?? "null"}) before ${path} appeared`;
|
|
139
126
|
const monitorChild = (child, label, path) => {
|
|
@@ -163,6 +150,7 @@ const monitorChild = (child, label, path) => {
|
|
|
163
150
|
path,
|
|
164
151
|
read: () => log,
|
|
165
152
|
failure: () => failure,
|
|
153
|
+
isRunning: () => child.exitCode === null && child.signalCode === null && isProcessAlive(child.pid),
|
|
166
154
|
subscribe: (notify) => {
|
|
167
155
|
subscribers.add(notify);
|
|
168
156
|
return () => {
|
|
@@ -184,11 +172,9 @@ const runCleanups = (cleanups) => {
|
|
|
184
172
|
}
|
|
185
173
|
cleanups.length = 0;
|
|
186
174
|
};
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
}, 50);
|
|
175
|
+
const isMonitorReady = (monitor) => monitor.isRunning() && existsSync(monitor.path);
|
|
176
|
+
const pendingMonitors = (monitors) => monitors.filter((monitor) => !isMonitorReady(monitor));
|
|
177
|
+
const isEveryMonitorReady = (monitors) => monitors.every((monitor) => isMonitorReady(monitor));
|
|
192
178
|
const firstFailure = (monitors) => {
|
|
193
179
|
for (const monitor of monitors) {
|
|
194
180
|
const failure = monitor.failure();
|
|
@@ -198,66 +184,125 @@ const firstFailure = (monitors) => {
|
|
|
198
184
|
}
|
|
199
185
|
return undefined;
|
|
200
186
|
};
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
187
|
+
const SOCKET_TIMEOUT_MS = 15_000;
|
|
188
|
+
const missingMessage = (pending, timeout) => `${pending.map((monitor) => monitor.label).join(", ")} did not become available within ${String(timeout)}ms\n` +
|
|
189
|
+
pending.map((monitor) => monitor.read()).join("");
|
|
190
|
+
const watchForSockets = ({ options, resolve, reject }) => {
|
|
191
|
+
const { monitors, timeout = SOCKET_TIMEOUT_MS } = options;
|
|
192
|
+
const cleanups = monitors.map((monitor) => monitor.stop);
|
|
205
193
|
const stop = () => {
|
|
206
194
|
runCleanups(cleanups);
|
|
207
195
|
};
|
|
208
196
|
const fail = (message) => {
|
|
209
197
|
stop();
|
|
210
|
-
for (const guard of guards) {
|
|
211
|
-
guard.stop();
|
|
212
|
-
}
|
|
213
198
|
reject(new Error(message));
|
|
214
199
|
};
|
|
215
|
-
const alreadyFailed = firstFailure(
|
|
200
|
+
const alreadyFailed = firstFailure(monitors);
|
|
216
201
|
if (alreadyFailed !== undefined) {
|
|
217
202
|
fail(alreadyFailed);
|
|
218
203
|
return;
|
|
219
204
|
}
|
|
220
|
-
const
|
|
205
|
+
const settle = () => {
|
|
221
206
|
stop();
|
|
222
207
|
resolve();
|
|
223
|
-
}
|
|
208
|
+
};
|
|
209
|
+
if (isEveryMonitorReady(monitors)) {
|
|
210
|
+
settle();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const poll = setInterval(() => {
|
|
214
|
+
if (isEveryMonitorReady(monitors)) {
|
|
215
|
+
settle();
|
|
216
|
+
}
|
|
217
|
+
}, 10);
|
|
224
218
|
const timer = setTimeout(() => {
|
|
225
|
-
fail(
|
|
219
|
+
fail(missingMessage(pendingMonitors(monitors), timeout));
|
|
226
220
|
}, timeout);
|
|
227
221
|
cleanups.push(() => {
|
|
228
222
|
clearInterval(poll);
|
|
229
223
|
clearTimeout(timer);
|
|
230
|
-
}, ...
|
|
224
|
+
}, ...monitors.map((monitor) => monitor.subscribe(fail)));
|
|
231
225
|
};
|
|
232
|
-
const
|
|
233
|
-
|
|
226
|
+
const waitForSockets = (options) => new Promise((resolve, reject) => {
|
|
227
|
+
watchForSockets({ options, resolve, reject });
|
|
234
228
|
});
|
|
235
229
|
const captureCompositorStderr = (child, logPath) => {
|
|
236
230
|
const captured = [];
|
|
237
231
|
const stderr = child.stderr;
|
|
238
|
-
if (stderr
|
|
239
|
-
|
|
240
|
-
const logStream = createWriteStream(logPath);
|
|
241
|
-
logStream.on("error", () => undefined);
|
|
242
|
-
stderr.on("data", (chunk) => {
|
|
243
|
-
captured.push(chunk);
|
|
244
|
-
logStream.write(chunk);
|
|
245
|
-
});
|
|
232
|
+
if (stderr === null) {
|
|
233
|
+
return { chunks: captured, logPath, stop: noVirtualSeat };
|
|
246
234
|
}
|
|
247
|
-
|
|
235
|
+
stderr.setEncoding("utf8");
|
|
236
|
+
const descriptor = openSync(logPath, "a");
|
|
237
|
+
let isOpen = true;
|
|
238
|
+
const onData = (chunk) => {
|
|
239
|
+
captured.push(chunk);
|
|
240
|
+
try {
|
|
241
|
+
writeSync(descriptor, chunk);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
stderr.on("data", onData);
|
|
248
|
+
return {
|
|
249
|
+
chunks: captured,
|
|
250
|
+
logPath,
|
|
251
|
+
stop: () => {
|
|
252
|
+
stderr.removeListener("data", onData);
|
|
253
|
+
if (isOpen) {
|
|
254
|
+
isOpen = false;
|
|
255
|
+
closeSync(descriptor);
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
};
|
|
248
259
|
};
|
|
249
|
-
const compositorExitMessage = (code, signal,
|
|
250
|
-
|
|
251
|
-
|
|
260
|
+
const compositorExitMessage = (code, signal, captured) => `[gtkx] the headless compositor exited (code ${String(code)}, signal ${signal ?? "null"}); ` +
|
|
261
|
+
"every Wayland client in this worker has been severed. " +
|
|
262
|
+
`Its stderr log is kept at ${captured.logPath} until this worker exits.\n${captured.chunks.join("")}`;
|
|
263
|
+
const watchCompositorExit = (child, captured) => {
|
|
252
264
|
const report = (code, signal) => {
|
|
253
|
-
process.stderr.write(compositorExitMessage(code, signal,
|
|
265
|
+
process.stderr.write(compositorExitMessage(code, signal, captured));
|
|
254
266
|
};
|
|
255
267
|
child.on("exit", report);
|
|
256
268
|
return () => child.removeListener("exit", report);
|
|
257
269
|
};
|
|
258
|
-
const waitForDisplaySockets =
|
|
259
|
-
|
|
260
|
-
|
|
270
|
+
const waitForDisplaySockets = ({ compositorMonitor, busMonitor }) => waitForSockets({ monitors: [busMonitor, compositorMonitor] });
|
|
271
|
+
const CONNECT_RETRY_MS = 10;
|
|
272
|
+
const pause = (duration) => new Promise((resolve) => {
|
|
273
|
+
setTimeout(resolve, duration);
|
|
274
|
+
});
|
|
275
|
+
const connectFailure = (path) => new Promise((resolve) => {
|
|
276
|
+
const socket = connect(path);
|
|
277
|
+
socket.once("connect", () => {
|
|
278
|
+
socket.destroy();
|
|
279
|
+
resolve(undefined);
|
|
280
|
+
});
|
|
281
|
+
socket.once("error", (cause) => {
|
|
282
|
+
socket.destroy();
|
|
283
|
+
resolve(cause);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
const unreachableMessage = (monitor, cause) => `${monitor.label} did not accept a connection on ${monitor.path}: ${cause.message}\n${monitor.read()}`;
|
|
287
|
+
const waitUntilConnectable = async (monitor) => {
|
|
288
|
+
const deadline = Date.now() + SOCKET_TIMEOUT_MS;
|
|
289
|
+
for (;;) {
|
|
290
|
+
const failure = await connectFailure(monitor.path);
|
|
291
|
+
if (failure === undefined) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (!monitor.isRunning() || Date.now() >= deadline) {
|
|
295
|
+
throw new Error(unreachableMessage(monitor, failure));
|
|
296
|
+
}
|
|
297
|
+
await pause(CONNECT_RETRY_MS);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
const attachCompositorClient = async (compositor, monitor) => {
|
|
301
|
+
if (compositor.requiresVirtualSeat) {
|
|
302
|
+
return startVirtualSeat(monitor.path);
|
|
303
|
+
}
|
|
304
|
+
await waitUntilConnectable(monitor);
|
|
305
|
+
return noVirtualSeat;
|
|
261
306
|
};
|
|
262
307
|
const killSpawned = (children) => {
|
|
263
308
|
for (const child of children) {
|
|
@@ -274,22 +319,37 @@ const makeTeardown = (stops) => {
|
|
|
274
319
|
runCleanups(stops);
|
|
275
320
|
};
|
|
276
321
|
};
|
|
322
|
+
const createHeadlessRuntimeDirectory = () => {
|
|
323
|
+
const runtimeDir = mkdtempSync(join(tmpdir(), "gtkx-xdg-"));
|
|
324
|
+
try {
|
|
325
|
+
chmodSync(runtimeDir, 0o700);
|
|
326
|
+
const markerPath = join(runtimeDir, HEADLESS_RUNTIME_MARKER);
|
|
327
|
+
writeFileSync(markerPath, createHeadlessRuntimeMarker(runtimeDir), { flag: "wx", mode: 0o600 });
|
|
328
|
+
chmodSync(markerPath, 0o600);
|
|
329
|
+
return runtimeDir;
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
rmSync(runtimeDir, { recursive: true, force: true });
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
};
|
|
277
336
|
const startHeadlessDisplay = async (options) => {
|
|
278
337
|
const env = {};
|
|
279
|
-
const runtimeDir =
|
|
280
|
-
chmodSync(runtimeDir, 0o700);
|
|
338
|
+
const runtimeDir = createHeadlessRuntimeDirectory();
|
|
281
339
|
const spawned = [];
|
|
282
340
|
const removeRuntime = () => {
|
|
283
341
|
restoreEnv(env);
|
|
284
342
|
rmSync(runtimeDir, { recursive: true, force: true });
|
|
285
343
|
};
|
|
286
344
|
try {
|
|
345
|
+
applyEnv(env, STATIC_HEADLESS_ENV);
|
|
287
346
|
applyEnv(env, { XDG_RUNTIME_DIR: runtimeDir });
|
|
288
347
|
const busConfigPath = join(runtimeDir, "session.conf");
|
|
289
348
|
const busSocketPath = join(runtimeDir, "bus");
|
|
290
349
|
writeBusConfig(busConfigPath, busSocketPath);
|
|
291
350
|
const busChild = spawnWithParentDeathSignal("dbus-daemon", [`--config-file=${busConfigPath}`], {
|
|
292
351
|
stdio: ["ignore", "ignore", "pipe"],
|
|
352
|
+
cleanupDirectories: [runtimeDir],
|
|
293
353
|
});
|
|
294
354
|
busChild.unref();
|
|
295
355
|
spawned.push(busChild);
|
|
@@ -302,12 +362,13 @@ const startHeadlessDisplay = async (options) => {
|
|
|
302
362
|
const compositorMonitor = monitorChild(compositor.child, "Compositor", compositorSocketPath);
|
|
303
363
|
applyEnv(env, { WAYLAND_DISPLAY: compositor.socket });
|
|
304
364
|
await waitForDisplaySockets({ compositorMonitor, busMonitor });
|
|
305
|
-
const stopVirtualSeat = await
|
|
365
|
+
const stopVirtualSeat = await attachCompositorClient(compositor, compositorMonitor);
|
|
306
366
|
const stopNotifications = await startNotificationService(`unix:path=${busSocketPath}`);
|
|
307
367
|
const capturedStderr = captureCompositorStderr(compositor.child, join(runtimeDir, "compositor.stderr.log"));
|
|
308
368
|
const stopExitWatch = watchCompositorExit(compositor.child, capturedStderr);
|
|
309
369
|
return makeTeardown([
|
|
310
370
|
stopExitWatch,
|
|
371
|
+
capturedStderr.stop,
|
|
311
372
|
() => {
|
|
312
373
|
killSpawned(spawned);
|
|
313
374
|
},
|