initial commit
This commit is contained in:
parent
877455b386
commit
30102c9871
57 changed files with 9924 additions and 2955 deletions
460
Cargo.lock
generated
460
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
13
Cargo.toml
13
Cargo.toml
|
|
@ -12,6 +12,10 @@ path = "src/main.rs"
|
|||
name = "wo-portal"
|
||||
path = "src/bin/wo-portal.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "xdg-desktop-portal-wo"
|
||||
path = "src/bin/wo-portal.rs"
|
||||
|
||||
[dependencies]
|
||||
# Smithay compositor framework
|
||||
smithay = { version = "0.7", default-features = false, features = ["backend_drm", "backend_gbm", "backend_egl", "backend_libinput", "backend_udev", "backend_session_libseat", "backend_winit", "renderer_gl", "wayland_frontend", "desktop", "xwayland", "use_system_lib", "wayland-client", "backend_session"] }
|
||||
|
|
@ -21,7 +25,7 @@ calloop = { version = "0.14", features = ["executor"] }
|
|||
calloop-wayland-source = "0.4"
|
||||
|
||||
# DRM/GBM
|
||||
drm = "0.14"
|
||||
drm = "0.15"
|
||||
gbm = "0.18"
|
||||
|
||||
# EGL / GL
|
||||
|
|
@ -38,13 +42,14 @@ tokio = { version = "1", features = ["full"] }
|
|||
nix = { version = "0.31", features = ["socket", "uio", "fs", "net", "time"] }
|
||||
|
||||
# D-Bus and PipeWire for portal
|
||||
zbus = { version = "5", default-features = false, features = ["tokio"] }
|
||||
pipewire = { version = "0.9", default-features = false }
|
||||
zbus = { version = "6", default-features = false, features = ["tokio", "service"] }
|
||||
pipewire = { version = "0.10", default-features = false }
|
||||
|
||||
# Config
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "1.0"
|
||||
sha2 = "0.10"
|
||||
toml = "1.1"
|
||||
|
||||
# Platform syscalls (TIOCOUTQ ioctl for socket buffer checks)
|
||||
libc = "0.2"
|
||||
|
|
|
|||
8
PKGBUILD
8
PKGBUILD
|
|
@ -10,7 +10,7 @@ license=('MIT')
|
|||
provides=('wo')
|
||||
conflicts=('wo')
|
||||
depends=(
|
||||
'electron'
|
||||
'electron>=45'
|
||||
'libdrm'
|
||||
'libinput'
|
||||
'seatd'
|
||||
|
|
@ -19,7 +19,8 @@ depends=(
|
|||
'pipewire'
|
||||
'wayland'
|
||||
'xdg-desktop-portal'
|
||||
'xorg-xwayland'
|
||||
'xorg-xwayland',
|
||||
'python3'
|
||||
)
|
||||
makedepends=(
|
||||
'bun'
|
||||
|
|
@ -56,6 +57,8 @@ build() {
|
|||
|
||||
cd "$srcdir/comraw"
|
||||
bun install --no-audit --no-fund
|
||||
rm -rf node_modules/@wl-wo/wo-types
|
||||
cp -a "$srcdir/wo/electron/types" node_modules/@wl-wo/wo-types
|
||||
bun run build
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +67,7 @@ package() {
|
|||
|
||||
install -Dm755 target/release/wo "$pkgdir/usr/bin/wo"
|
||||
install -Dm755 target/release/wo-portal "$pkgdir/usr/bin/wo-portal"
|
||||
install -Dm755 target/release/xdg-desktop-portal-wo "$pkgdir/usr/bin/xdg-desktop-portal-wo"
|
||||
|
||||
install -Dm644 packaging/wayland-sessions/wo.desktop \
|
||||
"$pkgdir/usr/share/wayland-sessions/wo.desktop"
|
||||
|
|
|
|||
169
README.md
169
README.md
|
|
@ -1,3 +1,170 @@
|
|||
# wo compositor
|
||||
|
||||
don't bother (woe)
|
||||
Wo is an Electron-centered Smithay Wayland compositor. The portal backend is
|
||||
available as `xdg-desktop-portal-wo`; run `wo portals` to start it directly.
|
||||
The packaged xdg-desktop-portal configuration also starts it on demand.
|
||||
|
||||
## User-local install
|
||||
|
||||
With Rust, Bun, and Python 3 installed, run `just install` from this checkout.
|
||||
It builds Wo, Electron, and comraw, then installs them under `~/.local` and
|
||||
`$XDG_DATA_HOME/wo`. On first install it copies `config.example.toml` to
|
||||
`$XDG_CONFIG_HOME/wo/config.toml` and configures the copied comraw build to be
|
||||
served locally. Existing user configuration is preserved. Add `~/.local/bin`
|
||||
to `PATH` and select the Wo session from your display manager.
|
||||
|
||||
## Portal requests in the Wo API
|
||||
|
||||
Renderer code can subscribe to compositor-mediated requests and resolve them
|
||||
through the context-isolated API. Requests are denied by default if the
|
||||
application does not respond.
|
||||
|
||||
```ts
|
||||
const unsubscribe = window.woClient.portals.onRequest(async ({ requestId, payload }) => {
|
||||
const allowed = await showConsentDialog(payload);
|
||||
await window.woClient.portals.respond(requestId, {
|
||||
allowed,
|
||||
type: 'monitor', // or 'window'
|
||||
windowName: null,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
The API is also available as `window.compositor.portals` in compositor UI
|
||||
contexts. Portal approval transport uses a per-user runtime socket with owner
|
||||
only permissions.
|
||||
|
||||
## Desktop and system APIs
|
||||
|
||||
Wo exposes a typed convenience layer as `window.compositor` and
|
||||
`window.woClient`:
|
||||
|
||||
```ts
|
||||
const directories = await window.compositor.xdg.directories();
|
||||
const apps = await window.compositor.desktop.applications.list();
|
||||
await window.compositor.desktop.applications.launch('org.gnome.Terminal.desktop');
|
||||
const icon = await window.compositor.icons.resolve('utilities-terminal', { size: 48 });
|
||||
const names = await window.compositor.dbus.listNames('user');
|
||||
```
|
||||
|
||||
Desktop entries are read from the XDG data directories, localized names and
|
||||
user directories are honored, icons are resolved from XDG icon themes, and
|
||||
desktop `Exec` fields are launched as argument vectors without a shell. The
|
||||
D-Bus helpers use `busctl`; call arguments should include a D-Bus signature.
|
||||
`window.compositor.audio` lists PipeWire audio sources and can forward PCM from
|
||||
a desktop monitor source to the renderer. PipeWire and `pw-cat` must be
|
||||
available. Audio forwarding is exposed separately from ScreenCast because the
|
||||
standard XDG ScreenCast stream contains video, not a companion audio stream.
|
||||
|
||||
## Website-first Wo API and component overlays
|
||||
|
||||
Wo treats loaded pages as websites first: ordinary pages receive no compositor
|
||||
bridge. A site can publish `/_wo.capabilities.json` and
|
||||
`/_wo.components.css`. The manifest requests named APIs and binds the request to
|
||||
the exact `index.html` SHA-256, a stylesheet SHA-256, and SHA-256 values for its
|
||||
script assets. Wo prefetches and verifies those files before navigation, caches
|
||||
verified assets by URL and hash, then asks the user before exposing the
|
||||
requested API groups. Granting access applies only to that page/window; a
|
||||
top-level navigation revokes it. Scripts for a manifest-enabled page are served
|
||||
from Wo's verified cache, and unlisted script loads are blocked.
|
||||
|
||||
The available permission names are `window-management`, `overlay-rendering`,
|
||||
`desktop-applications`, `xdg`, `dbus`, `portals`, `filesystem`,
|
||||
`process-launch`, `system-control`, `notifications`, and `audio-capture`. A
|
||||
page can inspect `window.wo.permissions.requested`, `.granted`, and `.has(name)`.
|
||||
The compositor bridge groups (`window.compositor` and `window.woClient`) are
|
||||
present only after at least one API group is approved; unapproved groups are
|
||||
omitted and their IPC operations remain denied by the main process.
|
||||
|
||||
Pages granted `portals` can open a desktop context menu with
|
||||
`await window.wo.portals.contextMenu({ x, y, items })`. It resolves with the
|
||||
selected item ID after dismissal. Electron delegates the menu to the native
|
||||
desktop popup implementation, so Wayland presents it as a popup surface and it
|
||||
uses the desktop's native menu theme. Root portal UIs such as comraw's screen
|
||||
share prompt can be themed with `_wo.components.css`.
|
||||
|
||||
Comraw publishes `_wo.capabilities.json`, its schema, and `_wo.components.css`
|
||||
from `comraw/public`. The schema is `_wo.capabilities.schema.json`. Its Vite
|
||||
production build writes the current `index.html` hash, stylesheet hash, and
|
||||
script hashes into the public manifest, so the request stays tied to the exact
|
||||
build. The development server generates the matching index and stylesheet
|
||||
hashes on request. Wo sends the verified
|
||||
document and stylesheet bytes to the compositor, which computes SHA-256 itself
|
||||
and stores both hashes against the owning window. `_wo.components.css` provides
|
||||
component theme tokens and keyboard focus styling that Wo preloads and applies
|
||||
to the page. Websites can use the same manifest format to request a window
|
||||
surface or use the overlay-rendering bridge for HTML/CSS/JS surfaces.
|
||||
|
||||
Sites with `window-management` can subscribe to the compositor's monitor
|
||||
layout with `window.wo.windows?.monitors(callback)`. Sites with
|
||||
`overlay-rendering` can pin an element to a monitor's global desktop rectangle:
|
||||
|
||||
```ts
|
||||
const unmount = window.wo.overlay?.mount(element, monitor.id);
|
||||
// Call unmount() to restore the element's original inline positioning.
|
||||
```
|
||||
|
||||
Monitor coordinates are global desktop coordinates, so pointer positions and
|
||||
window placement remain consistent when an element is mounted on another
|
||||
display.
|
||||
|
||||
Comraw provides four persistent virtual desktops. Windows and their transient
|
||||
dialogs are assigned together, hidden from other desktops, and can be moved
|
||||
from the window titlebar; each monitor has an independent desktop selection,
|
||||
and Ctrl+Alt+Left/Right switches desktops on the monitor under the pointer.
|
||||
The DRM backend discovers connected outputs, assigns compatible CRTCs, places
|
||||
them side by side, and composites the shared desktop scene into a per-output
|
||||
swapchain. Native Wayland windows use the same global Smithay `Space` and can
|
||||
span monitors. Newly connected DRM outputs are detected by a two-second
|
||||
topology poll and attached when a compatible free CRTC is available. Unplug
|
||||
cleanup and live mode replacement are not implemented yet; restart Wo after
|
||||
removing a monitor or changing its mode. Workspace hiding remains a comraw
|
||||
shell feature; it does not isolate native Wayland clients. Portal monitor
|
||||
capture currently publishes the primary output rather than a stitched
|
||||
multi-monitor image.
|
||||
|
||||
## Native offscreen frames
|
||||
|
||||
On Linux the Electron renderer requests `webPreferences.offscreen.useSharedTexture`
|
||||
and forwards the native-pixmap DMA-BUF planes to Wo over SCM_RIGHTS. This avoids
|
||||
the JS bitmap readback and Wo's RGBA-to-DMA-BUF import for normal offscreen
|
||||
frames. Wo still imports the DMA-BUF into Smithay for composition. The Electron
|
||||
and CEF source trees used to verify upstream support are separate checkouts;
|
||||
their Linux shared-texture/accelerated-paint APIs already expose native pixmap
|
||||
planes, so Wo's integration uses those APIs rather than carrying a fork of the
|
||||
frame capture implementation. The CEF tree is not a current runtime backend.
|
||||
|
||||
`packaging/engines` contains GN argument presets for an optional source build.
|
||||
For Electron, copy `electron-wo.gn` into the Electron checkout's
|
||||
`build/args/wo.gn` and import it from the Chromium `out/<config>/args.gn` as
|
||||
`import("//electron/build/args/wo.gn")`. For CEF, copy `cef-wo.gn` into the CEF
|
||||
checkout's `tools/wo.gn` and import it as `import("//cef/tools/wo.gn")`. Both
|
||||
source builds need the matching Chromium checkout and platform toolchain. The
|
||||
Arch package currently links to the system Electron package, so those presets
|
||||
do not change its Electron binary. Ubuntu builds bundle Electron with
|
||||
`packaging/ubuntu/build-deb.sh` and install a matching launcher/runtime pair.
|
||||
See `packaging/ubuntu/README.md` for the build requirements and install command.
|
||||
|
||||
## GPU rendering and native input
|
||||
|
||||
Wo's compositor renderer uses Smithay GLES through EGL. Electron/Chromium
|
||||
content leaves GPU backend selection to Chromium by default. Vulkan is disabled
|
||||
for native Wayland Electron clients because Chromium's Wayland surface factory
|
||||
does not support it; this leaves Chromium free to use its GL/EGL path. Set
|
||||
`WO_ELECTRON_GPU_BACKEND=angle` or `gl` to select ANGLE OpenGL, `vulkan` for
|
||||
ANGLE/Vulkan, or `desktop` for native X11 GL. To test XWayland/GLX for Electron
|
||||
while Wo itself remains a Wayland compositor, launch with
|
||||
`WO_ELECTRON_OZONE_PLATFORM=x11 WO_ELECTRON_GPU_BACKEND=desktop`. Wo's nested
|
||||
backend prefers an inherited `WAYLAND_DISPLAY`, or discovers a `wayland-*`
|
||||
socket under `XDG_RUNTIME_DIR` when launchers omit that variable. GPU support
|
||||
still depends on the system driver and Chromium build.
|
||||
|
||||
Wayland clients receive pointer motion, buttons, scroll axes, relative pointer
|
||||
motion, and locked-pointer cursor hints through Smithay's native protocols.
|
||||
Forwarded input accounts for client-surface geometry offsets so window borders
|
||||
and client-side decorations do not skew pointer coordinates.
|
||||
|
||||
## Headless tests
|
||||
|
||||
Run the Electron renderer and buffer tests with `cd electron && bun run test`.
|
||||
Run configuration tests without starting Wayland with `cargo test --lib config::tests`.
|
||||
|
|
|
|||
1
comraw
Submodule
1
comraw
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 807af019a84995a047a4417d9decd1f64061384b
|
||||
|
|
@ -8,16 +8,10 @@ portal_socket = "$XDG_RUNTIME_DIR/wo-portal.sock"
|
|||
enable_syscalls = true
|
||||
focus_notify = true
|
||||
|
||||
# Environment variables passed to all spawned child processes.
|
||||
# DISPLAY defaults to ":0" so X11 apps (e.g. Steam) work even before
|
||||
# XWayland is ready. XWayland overrides DISPLAY with the actual value.
|
||||
[compositor.environment]
|
||||
DISPLAY = ":0"
|
||||
|
||||
[[root]]
|
||||
name = "main"
|
||||
url = "http://localhost:5173"
|
||||
fps = 60
|
||||
url = "http://127.0.0.1:5173"
|
||||
fps = 0 # 0 follows the display refresh rate; set a positive value to cap this window
|
||||
focusable = false
|
||||
interactive = false
|
||||
|
||||
|
|
@ -27,5 +21,6 @@ command = "wo://portal/start"
|
|||
delay = 100
|
||||
|
||||
[[autostart]]
|
||||
name = "vite"
|
||||
command = "cd /
|
||||
name = "desktop-site"
|
||||
command = "python3 -m http.server 5173 --bind 127.0.0.1 --directory \"$WO_DATA_DIR/comraw\" >/dev/null 2>&1"
|
||||
delay = 0
|
||||
|
|
|
|||
|
|
@ -9,16 +9,17 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/electron": "^latest",
|
||||
"@types/node": "^latest",
|
||||
"electron": "^29.0.0",
|
||||
"node-gyp": "^10.0.0",
|
||||
"typescript": "^latest",
|
||||
"@types/node": "^25.3.5",
|
||||
"electron": "^45.0.0",
|
||||
"node-gyp": "^10.3.1",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],
|
||||
"@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.6", "", {}, "sha512-/aQLgFDdCSnwChfUwqekSmE1q2dCTzhUy5saIHBb5vyYn5LzWaKPaIz1wM0lDMCZYBoIG4svJNvAJb6vkI8BuA=="],
|
||||
|
||||
"@electron/get": ["@electron/get@5.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA=="],
|
||||
|
||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
|
|
@ -28,26 +29,10 @@
|
|||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||
|
||||
"@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
||||
|
||||
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
|
||||
|
||||
"@types/electron": ["@types/electron@1.6.12", "", { "dependencies": { "electron": "*" } }, "sha512-NIJokDkGv9h+MStCL1IuiL1FOHYVkszoWeNxJtSI5dcEKRGbX83JcVYNAgk019qOQgJkHtz9WdP0CDXvrArrGg=="],
|
||||
|
||||
"@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="],
|
||||
|
||||
"@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
|
||||
|
||||
"@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="],
|
||||
|
||||
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
|
||||
|
||||
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
|
||||
|
||||
"abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
|
@ -60,26 +45,16 @@
|
|||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
|
||||
"cacache": ["cacache@18.0.4", "", { "dependencies": { "@npmcli/fs": "^3.1.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^4.0.0", "ssri": "^10.0.0", "tar": "^6.1.11", "unique-filename": "^3.0.0" } }, "sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ=="],
|
||||
|
||||
"cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
|
||||
|
||||
"cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="],
|
||||
|
||||
"chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="],
|
||||
|
||||
"clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="],
|
||||
|
||||
"clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
|
@ -88,72 +63,32 @@
|
|||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||
|
||||
"defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
|
||||
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
||||
|
||||
"detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
|
||||
"electron": ["electron@29.4.6", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^20.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-fz8ndj8cmmf441t4Yh2FDP3Rn0JhLkVGvtUf2YVMbJ5SdJPlc0JWll9jYkhh60jDKVVCr/tBAmfxqRnXMWJpzg=="],
|
||||
"electron": ["electron@45.0.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-yjz4uhMBcO6L5X7CWgRMobf2EMJPnb9j4zuf+nu/TU/6E4Sqfx7fn/a2eACo4qngjml/0JnsoZhxYroJLxLd2w=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="],
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
|
||||
|
||||
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
|
||||
|
||||
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
||||
|
||||
"fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="],
|
||||
|
||||
"get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
|
||||
|
||||
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
|
||||
|
||||
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"got": ["got@11.8.6", "", { "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" } }, "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
||||
|
||||
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
|
@ -172,24 +107,10 @@
|
|||
|
||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
|
||||
|
||||
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
|
||||
|
||||
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"make-fetch-happen": ["make-fetch-happen@13.0.1", "", { "dependencies": { "@npmcli/agent": "^2.0.0", "cacache": "^18.0.0", "http-cache-semantics": "^4.1.1", "is-lambda": "^1.0.1", "minipass": "^7.0.2", "minipass-fetch": "^3.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.3", "proc-log": "^4.2.0", "promise-retry": "^2.0.1", "ssri": "^10.0.0" } }, "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA=="],
|
||||
|
||||
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
||||
|
||||
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||
|
||||
"minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
|
||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
|
@ -218,14 +139,6 @@
|
|||
|
||||
"nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
|
||||
|
||||
"normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="],
|
||||
|
||||
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
|
||||
|
||||
"p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="],
|
||||
|
||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||
|
|
@ -234,34 +147,18 @@
|
|||
|
||||
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
||||
|
||||
"proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="],
|
||||
|
||||
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
|
||||
|
||||
"promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="],
|
||||
|
||||
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
||||
|
||||
"quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
|
||||
|
||||
"resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
|
||||
|
||||
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
|
||||
|
||||
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||
|
||||
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
|
||||
|
||||
"serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
|
@ -274,8 +171,6 @@
|
|||
|
||||
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
|
||||
|
||||
"ssri": ["ssri@10.0.6", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ=="],
|
||||
|
||||
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
|
@ -290,49 +185,33 @@
|
|||
|
||||
"tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="],
|
||||
|
||||
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@7.30.1", "", {}, "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"unique-filename": ["unique-filename@3.0.0", "", { "dependencies": { "unique-slug": "^4.0.0" } }, "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g=="],
|
||||
|
||||
"unique-slug": ["unique-slug@4.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ=="],
|
||||
|
||||
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
|
||||
|
||||
"@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@types/cacheable-request/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="],
|
||||
|
||||
"@types/keyv/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="],
|
||||
|
||||
"@types/responselike/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="],
|
||||
|
||||
"@types/yauzl/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="],
|
||||
"@electron/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="],
|
||||
|
||||
"cacache/fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="],
|
||||
|
||||
"clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"electron/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="],
|
||||
"electron/@types/node": ["@types/node@24.19.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ=="],
|
||||
|
||||
"fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
|
||||
|
|
@ -358,19 +237,11 @@
|
|||
|
||||
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"@types/cacheable-request/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@types/keyv/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@types/responselike/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@types/yauzl/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"electron/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
"electron/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
|
|
|
|||
2735
electron/dist/main.js
vendored
2735
electron/dist/main.js
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -2,10 +2,16 @@
|
|||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <atomic>
|
||||
#include <cerrno>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <poll.h>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
// Linux / GBM / DRM
|
||||
#include <fcntl.h>
|
||||
|
|
@ -211,6 +217,40 @@ static void release_buf(uint32_t tok) {
|
|||
pool_return(bo, w, h, stride);
|
||||
}
|
||||
|
||||
static gbm_bo *create_cpu_writable_texture(uint32_t w, uint32_t h) {
|
||||
// importRgba writes through gbm_bo_map(), so these buffers need a CPU
|
||||
// readable layout and compositor texture use. Requesting RENDERING here is
|
||||
// both unnecessary and rejected by some GBM drivers when combined with
|
||||
// LINEAR (notably proprietary NVIDIA GBM).
|
||||
constexpr uint32_t candidates[] = {
|
||||
GBM_BO_USE_LINEAR | GBM_BO_USE_WRITE,
|
||||
GBM_BO_USE_LINEAR,
|
||||
GBM_BO_USE_WRITE,
|
||||
0,
|
||||
};
|
||||
|
||||
for (uint32_t flags : candidates) {
|
||||
if (!gbm_device_is_format_supported(g_gbm, GBM_FORMAT_ARGB8888, flags))
|
||||
continue;
|
||||
gbm_bo *bo = gbm_bo_create(g_gbm, w, h, GBM_FORMAT_ARGB8888, flags);
|
||||
if (bo) return bo;
|
||||
}
|
||||
|
||||
// Some GBM backends implement modifiers more reliably than the legacy
|
||||
// create call. Keep the requested layout linear because the CPU maps it.
|
||||
const uint64_t linear = DRM_FORMAT_MOD_LINEAR;
|
||||
constexpr uint32_t modifier_flags[] = {
|
||||
GBM_BO_USE_LINEAR | GBM_BO_USE_WRITE,
|
||||
GBM_BO_USE_LINEAR,
|
||||
};
|
||||
for (uint32_t flags : modifier_flags) {
|
||||
gbm_bo *bo = gbm_bo_create_with_modifiers2(
|
||||
g_gbm, w, h, GBM_FORMAT_ARGB8888, &linear, 1, flags);
|
||||
if (bo) return bo;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Napi::Value Init(const Napi::CallbackInfo &info) {
|
||||
Napi::Env env = info.Env();
|
||||
std::string dev = "/dev/dri/renderD128";
|
||||
|
|
@ -238,6 +278,13 @@ Napi::Value ImportRgba(const Napi::CallbackInfo &info) {
|
|||
uint32_t h = info[2].As<Napi::Number>().Uint32Value();
|
||||
uint32_t src_stride = info[3].As<Napi::Number>().Uint32Value();
|
||||
|
||||
if (w == 0 || h == 0 || static_cast<uint64_t>(src_stride) < static_cast<uint64_t>(w) * 4U ||
|
||||
pixel_buf.Length() < static_cast<size_t>(src_stride) * h) {
|
||||
Napi::RangeError::New(env, "importRgba requires non-zero dimensions and a complete pixel buffer")
|
||||
.ThrowAsJavaScriptException();
|
||||
return env.Null();
|
||||
}
|
||||
|
||||
ensure_gbm();
|
||||
if (!g_gbm) {
|
||||
Napi::Error::New(env, "GBM device not initialised")
|
||||
|
|
@ -250,20 +297,20 @@ Napi::Value ImportRgba(const Napi::CallbackInfo &info) {
|
|||
gbm_bo *bo = pool_acquire(w, h, &dst_stride);
|
||||
|
||||
if (!bo) {
|
||||
bo = gbm_bo_create_with_modifiers2(g_gbm, w, h, GBM_FORMAT_ARGB8888,
|
||||
(const uint64_t[]){DRM_FORMAT_MOD_LINEAR},
|
||||
1,
|
||||
GBM_BO_USE_RENDERING | GBM_BO_USE_LINEAR);
|
||||
if (!bo) {
|
||||
bo = gbm_bo_create(g_gbm, w, h, GBM_FORMAT_ARGB8888,
|
||||
GBM_BO_USE_RENDERING | GBM_BO_USE_LINEAR);
|
||||
}
|
||||
if (!bo) {
|
||||
bo = gbm_bo_create(g_gbm, w, h, GBM_FORMAT_ARGB8888, GBM_BO_USE_WRITE);
|
||||
}
|
||||
bo = create_cpu_writable_texture(w, h);
|
||||
if (!bo) {
|
||||
const char *backend = gbm_device_get_backend_name(g_gbm);
|
||||
const bool format_write = gbm_device_is_format_supported(
|
||||
g_gbm, GBM_FORMAT_ARGB8888, GBM_BO_USE_LINEAR | GBM_BO_USE_WRITE);
|
||||
const bool format_linear = gbm_device_is_format_supported(
|
||||
g_gbm, GBM_FORMAT_ARGB8888, GBM_BO_USE_LINEAR);
|
||||
Napi::Error::New(
|
||||
env, "gbm_bo_create failed: all buffer creation methods exhausted")
|
||||
env, "gbm_bo_create failed: all buffer creation methods exhausted for " +
|
||||
std::to_string(w) + "x" + std::to_string(h) +
|
||||
" ARGB8888; backend=" + (backend ? backend : "unknown") +
|
||||
" linear-write-supported=" + (format_write ? "yes" : "no") +
|
||||
" linear-supported=" + (format_linear ? "yes" : "no") +
|
||||
"; check Wo and Electron are using the same DRM render node")
|
||||
.ThrowAsJavaScriptException();
|
||||
return env.Null();
|
||||
}
|
||||
|
|
@ -401,6 +448,122 @@ Napi::Value RecvFd(const Napi::CallbackInfo &info) {
|
|||
return Napi::Number::New(env, -1);
|
||||
}
|
||||
|
||||
struct FdReadPacket {
|
||||
std::vector<uint8_t> bytes;
|
||||
std::vector<int> fds;
|
||||
bool closed = false;
|
||||
};
|
||||
|
||||
struct FdReader {
|
||||
int fd;
|
||||
std::atomic<bool> stopping{false};
|
||||
Napi::ThreadSafeFunction callback;
|
||||
std::thread worker;
|
||||
};
|
||||
|
||||
static std::mutex g_fd_readers_mutex;
|
||||
static std::map<int, std::shared_ptr<FdReader>> g_fd_readers;
|
||||
|
||||
static void stop_fd_reader(int fd) {
|
||||
std::shared_ptr<FdReader> reader;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_fd_readers_mutex);
|
||||
auto it = g_fd_readers.find(fd);
|
||||
if (it == g_fd_readers.end()) return;
|
||||
reader = it->second;
|
||||
g_fd_readers.erase(it);
|
||||
}
|
||||
reader->stopping.store(true);
|
||||
if (reader->worker.joinable()) reader->worker.join();
|
||||
reader->callback.Release();
|
||||
}
|
||||
|
||||
Napi::Value StartFdReader(const Napi::CallbackInfo &info) {
|
||||
Napi::Env env = info.Env();
|
||||
if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsFunction()) {
|
||||
Napi::TypeError::New(env, "startFdReader(socketFd, callback)")
|
||||
.ThrowAsJavaScriptException();
|
||||
return env.Undefined();
|
||||
}
|
||||
|
||||
const int fd = info[0].As<Napi::Number>().Int32Value();
|
||||
stop_fd_reader(fd);
|
||||
|
||||
auto reader = std::make_shared<FdReader>();
|
||||
reader->fd = fd;
|
||||
reader->callback = Napi::ThreadSafeFunction::New(
|
||||
env, info[1].As<Napi::Function>(), "wo-fd-reader", 0, 1);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_fd_readers_mutex);
|
||||
g_fd_readers.emplace(fd, reader);
|
||||
}
|
||||
|
||||
reader->worker = std::thread([reader]() {
|
||||
while (!reader->stopping.load()) {
|
||||
pollfd pfd{reader->fd, POLLIN | POLLERR | POLLHUP, 0};
|
||||
const int ready = ::poll(&pfd, 1, 50);
|
||||
if (ready < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
break;
|
||||
}
|
||||
if (ready == 0) continue;
|
||||
|
||||
uint8_t data[64 * 1024];
|
||||
char control[CMSG_SPACE(sizeof(int) * 16)] = {};
|
||||
iovec iov{data, sizeof(data)};
|
||||
msghdr msg{};
|
||||
msg.msg_iov = &iov;
|
||||
msg.msg_iovlen = 1;
|
||||
msg.msg_control = control;
|
||||
msg.msg_controllen = sizeof(control);
|
||||
const ssize_t count = ::recvmsg(reader->fd, &msg, MSG_DONTWAIT | MSG_CMSG_CLOEXEC);
|
||||
if (count < 0) {
|
||||
if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) continue;
|
||||
break;
|
||||
}
|
||||
|
||||
auto *packet = new FdReadPacket();
|
||||
packet->closed = count == 0;
|
||||
if (count > 0) packet->bytes.assign(data, data + count);
|
||||
for (cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); cmsg; cmsg = CMSG_NXTHDR(&msg, cmsg)) {
|
||||
if (cmsg->cmsg_level != SOL_SOCKET || cmsg->cmsg_type != SCM_RIGHTS) continue;
|
||||
const size_t payloadBytes = cmsg->cmsg_len - CMSG_LEN(0);
|
||||
const size_t fdCount = payloadBytes / sizeof(int);
|
||||
const int *received = reinterpret_cast<const int *>(CMSG_DATA(cmsg));
|
||||
packet->fds.insert(packet->fds.end(), received, received + fdCount);
|
||||
}
|
||||
|
||||
const napi_status status = reader->callback.NonBlockingCall(
|
||||
packet, [](Napi::Env callbackEnv, Napi::Function callback, FdReadPacket *data) {
|
||||
Napi::Buffer<uint8_t> bytes = data->bytes.empty()
|
||||
? Napi::Buffer<uint8_t>::New(callbackEnv, 0)
|
||||
: Napi::Buffer<uint8_t>::Copy(
|
||||
callbackEnv, data->bytes.data(), data->bytes.size());
|
||||
Napi::Array fds = Napi::Array::New(callbackEnv, data->fds.size());
|
||||
for (size_t i = 0; i < data->fds.size(); ++i) {
|
||||
fds.Set(i, Napi::Number::New(callbackEnv, data->fds[i]));
|
||||
}
|
||||
callback.Call({bytes, fds, Napi::Boolean::New(callbackEnv, data->closed)});
|
||||
delete data;
|
||||
});
|
||||
if (status != napi_ok) {
|
||||
for (int receivedFd : packet->fds) ::close(receivedFd);
|
||||
delete packet;
|
||||
}
|
||||
if (count == 0) break;
|
||||
}
|
||||
});
|
||||
|
||||
return env.Undefined();
|
||||
}
|
||||
|
||||
Napi::Value StopFdReader(const Napi::CallbackInfo &info) {
|
||||
if (info.Length() >= 1 && info[0].IsNumber()) {
|
||||
stop_fd_reader(info[0].As<Napi::Number>().Int32Value());
|
||||
}
|
||||
return info.Env().Undefined();
|
||||
}
|
||||
|
||||
Napi::Value ImportDmabufTexture(const Napi::CallbackInfo &info) {
|
||||
Napi::Env env = info.Env();
|
||||
|
||||
|
|
@ -565,10 +728,6 @@ Napi::Value MmapFd(const Napi::CallbackInfo &info) {
|
|||
hint);
|
||||
}
|
||||
|
||||
// ── Persistent mmap cache ─────────────────────────────────────────────
|
||||
// CopyMmapToSab and CopyMmapDamageToSab are called on every frame for
|
||||
// the same fd+size. Avoid the mmap/munmap syscall pair per frame by
|
||||
// caching the mapping. Evict when fd or size changes.
|
||||
struct MmapCacheEntry {
|
||||
int fd;
|
||||
void *ptr;
|
||||
|
|
@ -683,6 +842,8 @@ Napi::Object ModuleInit(Napi::Env env, Napi::Object exports) {
|
|||
exports.Set("releaseBuffer", Napi::Function::New(env, ReleaseBuffer));
|
||||
exports.Set("sendFd", Napi::Function::New(env, SendFd));
|
||||
exports.Set("recvFd", Napi::Function::New(env, RecvFd));
|
||||
exports.Set("startFdReader", Napi::Function::New(env, StartFdReader));
|
||||
exports.Set("stopFdReader", Napi::Function::New(env, StopFdReader));
|
||||
exports.Set("mmapFd", Napi::Function::New(env, MmapFd));
|
||||
exports.Set("copyMmapToSab", Napi::Function::New(env, CopyMmapToSab));
|
||||
exports.Set("copyMmapDamageToSab", Napi::Function::New(env, CopyMmapDamageToSab));
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@
|
|||
"scripts": {
|
||||
"start": "electron dist/main.js",
|
||||
"dev": "bun src/main.ts",
|
||||
"build": "bun build src/main.ts src/preload.ts --outdir dist --target node --format esm --external electron",
|
||||
"build-native": "node-gyp rebuild --directory native",
|
||||
"test": "bun test",
|
||||
"build": "bun build src/main.ts --outdir dist --target node --format esm --external electron && bun build src/preload.ts --outfile dist/preload.js --target node --format cjs --external electron",
|
||||
"build-native": "node-gyp rebuild --directory native --jobs=1",
|
||||
"postinstall": "bun run build && bun run build-native"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -17,9 +18,8 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/electron": "^1.6.12",
|
||||
"@types/node": "^25.3.5",
|
||||
"electron": "^29.4.6",
|
||||
"electron": "^45.0.0",
|
||||
"node-gyp": "^10.3.1",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
|
|
|
|||
149
electron/src/damage-buffer.test.ts
Normal file
149
electron/src/damage-buffer.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { describe, expect, test } from 'bun:test';
|
||||
import { closeSync, mkdtempSync, openSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
applyDamagePayload,
|
||||
DamageBuffer,
|
||||
DamageRegionTracker,
|
||||
WoDmabufSender,
|
||||
} from './damage-buffer.js';
|
||||
import { MAGIC } from './protocol.js';
|
||||
import { createRendererDamageHelper } from './renderer-damage-helper.js';
|
||||
|
||||
describe('damage buffer', () => {
|
||||
test('copies padded full-frame rows into the destination stride', () => {
|
||||
const buffer = new DamageBuffer(2, 2);
|
||||
const source = Uint8Array.from([
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 90, 91, 92, 93,
|
||||
9, 10, 11, 12, 13, 14, 15, 16, 94, 95, 96, 97,
|
||||
]);
|
||||
|
||||
buffer.applyFullFrame(source, 12);
|
||||
|
||||
expect([...buffer.getFrameBuffer()]).toEqual([
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
9, 10, 11, 12, 13, 14, 15, 16,
|
||||
]);
|
||||
});
|
||||
|
||||
test('clips patches without shifting source pixels', () => {
|
||||
const buffer = new DamageBuffer(2, 2);
|
||||
buffer.applyFullFrame(new Uint8Array(16));
|
||||
|
||||
buffer.applyPatch({
|
||||
rect: { x: -1, y: -1, width: 3, height: 3 },
|
||||
stride: 12,
|
||||
rgba: Uint8Array.from([
|
||||
1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3,
|
||||
4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6,
|
||||
7, 7, 7, 7, 8, 8, 8, 8, 9, 9, 9, 9,
|
||||
]),
|
||||
});
|
||||
|
||||
expect([...buffer.getFrameBuffer()]).toEqual([
|
||||
5, 5, 5, 5, 6, 6, 6, 6,
|
||||
8, 8, 8, 8, 9, 9, 9, 9,
|
||||
]);
|
||||
});
|
||||
|
||||
test('tracks touching damage and ignores empty rectangles', () => {
|
||||
const tracker = new DamageRegionTracker();
|
||||
tracker.addAll([
|
||||
{ x: 0, y: 0, width: 2, height: 2 },
|
||||
{ x: 2, y: 0, width: 1, height: 2 },
|
||||
{ x: 8, y: 8, width: 0, height: 1 },
|
||||
]);
|
||||
|
||||
expect(tracker.take()).toEqual([{ x: 0, y: 0, width: 3, height: 2 }]);
|
||||
expect(tracker.take()).toEqual([]);
|
||||
});
|
||||
|
||||
test('renderer damage helper emits patches that reconstruct the updated frame', async () => {
|
||||
const payloads: Parameters<typeof applyDamagePayload>[1][] = [];
|
||||
const helper = createRendererDamageHelper(async (payload) => {
|
||||
payloads.push(payload);
|
||||
return { ok: true };
|
||||
}, { width: 3, height: 2, fullFrameThreshold: 1 });
|
||||
|
||||
helper.updatePatch(
|
||||
{ x: -1, y: 0, width: 3, height: 1 },
|
||||
Uint8Array.from([1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3]),
|
||||
);
|
||||
await helper.flush();
|
||||
|
||||
const rendered = new DamageBuffer(3, 2);
|
||||
const payload = payloads[0];
|
||||
expect(payload?.patches).toEqual([{
|
||||
rect: { x: 0, y: 0, width: 2, height: 1 },
|
||||
rgba: Uint8Array.from([2, 2, 2, 2, 3, 3, 3, 3]),
|
||||
stride: 8,
|
||||
}]);
|
||||
if (payload) applyDamagePayload(rendered, payload);
|
||||
expect([...rendered.getFrameBuffer()]).toEqual([
|
||||
2, 2, 2, 2, 3, 3, 3, 3,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared DMA-BUF frame forwarding', () => {
|
||||
test('holds a GBM buffer until the compositor acknowledges its frame', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'wo-buffer-'));
|
||||
const file = join(directory, 'frame');
|
||||
const socketFd = openSync(file, 'w+');
|
||||
const released: number[] = [];
|
||||
try {
|
||||
const socket = { _handle: { fd: socketFd } } as never;
|
||||
const native = {
|
||||
importRgba: () => ({ fd: 31, offset: 0, stride: 8, modifier_hi: 0, modifier_lo: 0, token: 7 }),
|
||||
sendFd: () => {},
|
||||
releaseBuffer: (token: number) => released.push(token),
|
||||
} as never;
|
||||
const sender = new WoDmabufSender(socket, 'web', native);
|
||||
const seq = sender.send(new DamageBuffer(2, 2));
|
||||
expect(released).toEqual([]);
|
||||
sender.acknowledge(seq);
|
||||
expect(released).toEqual([7]);
|
||||
} finally {
|
||||
closeSync(socketFd);
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('writes plane metadata and forwards original file descriptors', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'wo-frame-'));
|
||||
const file = join(directory, 'frame');
|
||||
const socketFd = openSync(file, 'w+');
|
||||
const sentFds: number[] = [];
|
||||
try {
|
||||
const socket = { _handle: { fd: socketFd } } as never;
|
||||
const sender = new WoDmabufSender(socket, 'web', { sendFd: (_fd: number, fd: number) => sentFds.push(fd) } as never);
|
||||
let released = 0;
|
||||
const seq = sender.sendSharedTexture({
|
||||
pixelFormat: 'bgra',
|
||||
codedSize: { width: 2, height: 3 },
|
||||
handle: { nativePixmap: { modifier: '0', planes: [
|
||||
{ fd: 21, stride: 8, offset: 0 },
|
||||
{ fd: 22, stride: 8, offset: 24 },
|
||||
] } },
|
||||
}, () => { released += 1; });
|
||||
|
||||
const bytes = readFileSync(file);
|
||||
const planeCountOffset = 8 + Buffer.byteLength('web') + 8 + 4 + 4 + 4;
|
||||
expect(bytes.readUInt32LE(0)).toBe(MAGIC.FRAME);
|
||||
expect(bytes.readUInt32LE(planeCountOffset)).toBe(2);
|
||||
expect(bytes.readUInt32LE(planeCountOffset + 4)).toBe(0);
|
||||
expect(bytes.readUInt32LE(planeCountOffset + 8)).toBe(8);
|
||||
expect(bytes.readUInt32LE(planeCountOffset + 20)).toBe(24);
|
||||
expect(sentFds).toEqual([21, 22]);
|
||||
expect(released).toBe(0);
|
||||
sender.acknowledge(seq);
|
||||
expect(released).toBe(1);
|
||||
} finally {
|
||||
closeSync(socketFd);
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -59,6 +59,8 @@ type NativeDmabufModule = {
|
|||
releaseBuffer: (token: number) => void;
|
||||
sendFd: (socketFd: number, dmabufFd: number) => void;
|
||||
recvFd: (socketFd: number) => number;
|
||||
startFdReader: (socketFd: number, callback: (chunk: Buffer, fds: number[], closed: boolean) => void) => void;
|
||||
stopFdReader: (socketFd: number) => void;
|
||||
mmapFd: (fd: number, size: number) => Buffer;
|
||||
copyMmapToSab: (fd: number, sab: SharedArrayBuffer, size: number, srcOff?: number, dstOff?: number) => void;
|
||||
copyMmapDamageToSab: (fd: number, sab: SharedArrayBuffer, stride: number, rects: Array<{y: number; h: number}>) => void;
|
||||
|
|
@ -67,6 +69,24 @@ type NativeDmabufModule = {
|
|||
releaseTexture: (windowName: string) => void;
|
||||
};
|
||||
|
||||
export interface OffscreenNativePixmapPlane {
|
||||
fd: number;
|
||||
stride: number;
|
||||
offset: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface OffscreenSharedTextureInfo {
|
||||
pixelFormat: 'bgra' | 'rgba' | 'rgbaf16' | 'nv12';
|
||||
codedSize: { width: number; height: number };
|
||||
handle: {
|
||||
nativePixmap: {
|
||||
planes: OffscreenNativePixmapPlane[];
|
||||
modifier: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const ARGB8888 = fourccCode('A', 'R', '2', '4');
|
||||
|
||||
export class DamageBuffer {
|
||||
|
|
@ -107,7 +127,6 @@ export class DamageBuffer {
|
|||
|
||||
applyFullFrame(frame: Uint8Array, frameStride: number = this.width * 4): void {
|
||||
if (frameStride === this.stride) {
|
||||
// Fast path: direct typed-array copy, no intermediate Buffer wrapper
|
||||
this.frame.set(new Uint8Array(frame.buffer, frame.byteOffset, this.frame.length));
|
||||
return;
|
||||
}
|
||||
|
|
@ -129,12 +148,14 @@ export class DamageBuffer {
|
|||
return;
|
||||
}
|
||||
|
||||
const patchStride = patch.stride ?? rect.width * 4;
|
||||
const patchStride = patch.stride ?? patch.rect.width * 4;
|
||||
const sourceX = rect.x - patch.rect.x;
|
||||
const sourceY = rect.y - patch.rect.y;
|
||||
const dstStartX = rect.x * 4;
|
||||
const rowBytes = rect.width * 4;
|
||||
|
||||
for (let row = 0; row < rect.height; row += 1) {
|
||||
const srcStart = row * patchStride;
|
||||
const srcStart = (sourceY + row) * patchStride + sourceX * 4;
|
||||
const dstStart = (rect.y + row) * this.stride + dstStartX;
|
||||
this.frame.set(
|
||||
new Uint8Array(patch.rgba.buffer, patch.rgba.byteOffset + srcStart, rowBytes),
|
||||
|
|
@ -152,29 +173,28 @@ export class DamageBuffer {
|
|||
|
||||
export class WoDmabufSender {
|
||||
private readonly socket: Socket;
|
||||
private readonly windowName: string;
|
||||
private readonly native: NativeDmabufModule;
|
||||
private seq = 1n;
|
||||
// Pre-allocated wire buffer: header (variable) + plane (16 bytes).
|
||||
// Layout: MAGIC(4) + nameLen(4) + name(N) + seq(8) + w(4) + h(4) + fmt(4) + planes(4) + plane(16)
|
||||
private readonly wireBuf: Buffer;
|
||||
private readonly sharedWireBuf: Buffer;
|
||||
private readonly nameBuf: Buffer;
|
||||
private readonly nameLen: number;
|
||||
private readonly seqOffset: number;
|
||||
private readonly widthOffset: number;
|
||||
private readonly heightOffset: number;
|
||||
private readonly pendingReleases = new Map<string, () => void>();
|
||||
|
||||
constructor(socket: Socket, windowName: string, native: NativeDmabufModule) {
|
||||
this.socket = socket;
|
||||
this.windowName = windowName;
|
||||
this.native = native;
|
||||
|
||||
const nameBuf = Buffer.from(windowName, 'utf8');
|
||||
this.nameBuf = nameBuf;
|
||||
this.nameLen = nameBuf.length;
|
||||
// header = 4+4+nameLen+8+4+4+4+4 = 32+nameLen, plus 16 bytes for plane
|
||||
const totalLen = 32 + this.nameLen + 16;
|
||||
this.wireBuf = Buffer.alloc(totalLen);
|
||||
this.sharedWireBuf = Buffer.alloc(32 + this.nameLen + 4 * 16);
|
||||
|
||||
// Static fields that never change
|
||||
let off = 0;
|
||||
this.wireBuf.writeUInt32LE(MAGIC.FRAME, off); off += 4;
|
||||
this.wireBuf.writeUInt32LE(this.nameLen, off); off += 4;
|
||||
|
|
@ -183,10 +203,16 @@ export class WoDmabufSender {
|
|||
this.widthOffset = off; off += 4;
|
||||
this.heightOffset = off; off += 4;
|
||||
this.wireBuf.writeUInt32LE(ARGB8888, off); off += 4;
|
||||
this.wireBuf.writeUInt32LE(1, off); // planes count = 1
|
||||
this.wireBuf.writeUInt32LE(1, off);
|
||||
}
|
||||
|
||||
send(buffer: DamageBuffer): bigint {
|
||||
if (buffer.getWidth() <= 0 || buffer.getHeight() <= 0
|
||||
|| buffer.getStride() < buffer.getWidth() * 4
|
||||
|| buffer.getFrameBuffer().byteLength < buffer.getStride() * buffer.getHeight()) {
|
||||
throw new RangeError('Cannot send an incomplete or zero-sized DMA-BUF frame');
|
||||
}
|
||||
|
||||
const imported = this.native.importRgba(
|
||||
buffer.getFrameBuffer(),
|
||||
buffer.getWidth(),
|
||||
|
|
@ -194,15 +220,15 @@ export class WoDmabufSender {
|
|||
buffer.getStride(),
|
||||
);
|
||||
|
||||
let sent = false;
|
||||
const sentSeq = this.seq;
|
||||
try {
|
||||
const socketFd = getSocketFd(this.socket);
|
||||
|
||||
// Write per-frame fields into the pre-allocated buffer
|
||||
this.wireBuf.writeBigUInt64LE(this.seq, this.seqOffset);
|
||||
this.wireBuf.writeUInt32LE(buffer.getWidth(), this.widthOffset);
|
||||
this.wireBuf.writeUInt32LE(buffer.getHeight(), this.heightOffset);
|
||||
|
||||
// Plane data (last 16 bytes of wireBuf)
|
||||
const planeOff = this.wireBuf.length - 16;
|
||||
this.wireBuf.writeUInt32LE(imported.offset, planeOff);
|
||||
this.wireBuf.writeUInt32LE(imported.stride, planeOff + 4);
|
||||
|
|
@ -212,13 +238,91 @@ export class WoDmabufSender {
|
|||
writeSync(socketFd, this.wireBuf, 0, this.wireBuf.length);
|
||||
this.native.sendFd(socketFd, imported.fd);
|
||||
|
||||
const sentSeq = this.seq;
|
||||
this.pendingReleases.set(sentSeq.toString(), () => this.native.releaseBuffer(imported.token));
|
||||
this.seq += 1n;
|
||||
sent = true;
|
||||
return sentSeq;
|
||||
} finally {
|
||||
this.native.releaseBuffer(imported.token);
|
||||
if (!sent) this.native.releaseBuffer(imported.token);
|
||||
}
|
||||
}
|
||||
|
||||
sendSharedTexture(texture: OffscreenSharedTextureInfo, release: () => void): bigint {
|
||||
const pixmap = texture.handle?.nativePixmap;
|
||||
const planes = pixmap?.planes;
|
||||
if (!planes || planes.length < 1 || planes.length > 4) {
|
||||
throw new Error('shared texture has no supported DMA-BUF planes');
|
||||
}
|
||||
const fourcc = texture.pixelFormat === 'bgra'
|
||||
? fourccCode('A', 'R', '2', '4')
|
||||
: texture.pixelFormat === 'rgba'
|
||||
? fourccCode('A', 'B', '2', '4')
|
||||
: texture.pixelFormat === 'nv12'
|
||||
? fourccCode('N', 'V', '1', '2')
|
||||
: 0;
|
||||
if (fourcc === 0) throw new Error(`unsupported shared texture pixel format: ${texture.pixelFormat}`);
|
||||
|
||||
const width = texture.codedSize.width;
|
||||
const height = texture.codedSize.height;
|
||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
|
||||
throw new Error('shared texture has invalid coded dimensions');
|
||||
}
|
||||
|
||||
const headerLength = 32 + this.nameLen + planes.length * 16;
|
||||
let off = 0;
|
||||
this.sharedWireBuf.writeUInt32LE(MAGIC.FRAME, off); off += 4;
|
||||
this.sharedWireBuf.writeUInt32LE(this.nameLen, off); off += 4;
|
||||
this.nameBuf.copy(this.sharedWireBuf, off); off += this.nameLen;
|
||||
this.sharedWireBuf.writeBigUInt64LE(this.seq, off); off += 8;
|
||||
this.sharedWireBuf.writeUInt32LE(width, off); off += 4;
|
||||
this.sharedWireBuf.writeUInt32LE(height, off); off += 4;
|
||||
this.sharedWireBuf.writeUInt32LE(fourcc, off); off += 4;
|
||||
this.sharedWireBuf.writeUInt32LE(planes.length, off); off += 4;
|
||||
const modifier = BigInt(pixmap.modifier || '0');
|
||||
for (const plane of planes) {
|
||||
this.sharedWireBuf.writeUInt32LE(plane.offset >>> 0, off); off += 4;
|
||||
this.sharedWireBuf.writeUInt32LE(plane.stride >>> 0, off); off += 4;
|
||||
this.sharedWireBuf.writeUInt32LE(Number((modifier >> 32n) & 0xffff_ffffn), off); off += 4;
|
||||
this.sharedWireBuf.writeUInt32LE(Number(modifier & 0xffff_ffffn), off); off += 4;
|
||||
}
|
||||
|
||||
const socketFd = getSocketFd(this.socket);
|
||||
for (const plane of planes) {
|
||||
if (!Number.isInteger(plane.fd) || plane.fd < 0 || !Number.isInteger(plane.stride) || plane.stride <= 0 || !Number.isInteger(plane.offset) || plane.offset < 0) {
|
||||
throw new Error('shared texture has invalid DMA-BUF plane metadata');
|
||||
}
|
||||
}
|
||||
|
||||
writeAllSync(socketFd, this.sharedWireBuf, headerLength);
|
||||
for (const plane of planes) this.native.sendFd(socketFd, plane.fd);
|
||||
|
||||
const sentSeq = this.seq;
|
||||
this.pendingReleases.set(sentSeq.toString(), release);
|
||||
this.seq += 1n;
|
||||
return sentSeq;
|
||||
}
|
||||
|
||||
acknowledge(seq: string | bigint): void {
|
||||
const key = seq.toString();
|
||||
const release = this.pendingReleases.get(key);
|
||||
if (!release) return;
|
||||
this.pendingReleases.delete(key);
|
||||
release();
|
||||
}
|
||||
|
||||
releaseAll(): void {
|
||||
for (const release of this.pendingReleases.values()) release();
|
||||
this.pendingReleases.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function writeAllSync(fd: number, buffer: Buffer, length: number): void {
|
||||
let offset = 0;
|
||||
while (offset < length) {
|
||||
const written = writeSync(fd, buffer, offset, length - offset);
|
||||
if (written <= 0) throw new Error('IPC socket closed while sending a DMA-BUF frame');
|
||||
offset += written;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyDamagePayload(buffer: DamageBuffer, payload: DamageFramePayload): void {
|
||||
|
|
@ -257,13 +361,12 @@ function clampRect(rect: DamageRect, width: number, height: number): DamageRect
|
|||
}
|
||||
|
||||
function coalesceDamageRects(input: DamageRect[], maxRects: number): DamageRect[] {
|
||||
if (input.length <= 1) {
|
||||
return input.slice();
|
||||
}
|
||||
|
||||
const rects = input
|
||||
.map((r) => ({ ...r }))
|
||||
.filter((r) => r.width > 0 && r.height > 0);
|
||||
if (rects.length <= 1) {
|
||||
return rects;
|
||||
}
|
||||
|
||||
const out: DamageRect[] = [];
|
||||
while (rects.length > 0) {
|
||||
|
|
|
|||
23
electron/src/frame-pacing.test.ts
Normal file
23
electron/src/frame-pacing.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, test } from 'bun:test';
|
||||
import { chooseFrameRate, frameIntervalMs } from './frame-pacing';
|
||||
|
||||
describe('per-display frame pacing', () => {
|
||||
test('follows the display refresh when no FPS override is configured', () => {
|
||||
expect(chooseFrameRate(0, 144)).toBe(144);
|
||||
expect(chooseFrameRate(undefined, 360)).toBe(360);
|
||||
});
|
||||
|
||||
test('caps a per-window override at its display refresh', () => {
|
||||
expect(chooseFrameRate(240, 144)).toBe(144);
|
||||
expect(chooseFrameRate(120, 360)).toBe(120);
|
||||
});
|
||||
|
||||
test('does not impose a global FPS ceiling above high-refresh displays', () => {
|
||||
expect(chooseFrameRate(1000, 360)).toBe(360);
|
||||
});
|
||||
|
||||
test('falls back to 60 Hz when display information is unavailable', () => {
|
||||
expect(chooseFrameRate(0, 0)).toBe(60);
|
||||
expect(frameIntervalMs(144)).toBeCloseTo(1000 / 144);
|
||||
});
|
||||
});
|
||||
13
electron/src/frame-pacing.ts
Normal file
13
electron/src/frame-pacing.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export function chooseFrameRate(configuredFps: number | undefined, displayFps: number | undefined): number {
|
||||
const display = Number.isFinite(displayFps) && (displayFps ?? 0) > 0
|
||||
? Math.round(displayFps!)
|
||||
: 60;
|
||||
const configured = Number.isFinite(configuredFps) && (configuredFps ?? 0) > 0
|
||||
? Math.round(configuredFps!)
|
||||
: display;
|
||||
return Math.max(1, Math.min(configured, display));
|
||||
}
|
||||
|
||||
export function frameIntervalMs(fps: number): number {
|
||||
return 1000 / Math.max(1, fps);
|
||||
}
|
||||
1842
electron/src/main.ts
1842
electron/src/main.ts
File diff suppressed because it is too large
Load diff
71
electron/src/native-fd-reader.test.ts
Normal file
71
electron/src/native-fd-reader.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { describe, expect, test } from 'bun:test';
|
||||
import { closeSync, existsSync, fstatSync, mkdtempSync, openSync, rmSync, writeSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { createConnection, createServer, type Socket } from 'net';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const nativePath = fileURLToPath(new URL('../native/build/Release/wo_dmabuf.node', import.meta.url));
|
||||
|
||||
describe('native SCM_RIGHTS receiver', () => {
|
||||
if (!existsSync(nativePath) || process.env.WO_RUN_NATIVE_SOCKET_TESTS !== '1') {
|
||||
test.skip('reads socket bytes and ancillary file descriptors with recvmsg (set WO_RUN_NATIVE_SOCKET_TESTS=1)', () => {});
|
||||
return;
|
||||
}
|
||||
|
||||
test('preserves SCM_RIGHTS descriptors alongside the Unix stream bytes', async () => {
|
||||
const native = require(nativePath) as {
|
||||
startFdReader: (fd: number, callback: (bytes: Buffer, fds: number[], closed: boolean) => void) => void;
|
||||
stopFdReader: (fd: number) => void;
|
||||
sendFd: (socketFd: number, fd: number) => void;
|
||||
};
|
||||
const directory = mkdtempSync(join(tmpdir(), 'wo-fd-reader-'));
|
||||
const socketPath = join(directory, 'ipc.sock');
|
||||
const filePath = join(directory, 'source');
|
||||
const sourceFd = openSync(filePath, 'w+');
|
||||
writeSync(sourceFd, Buffer.from('dma-buf-fd'));
|
||||
|
||||
const server = createServer();
|
||||
const acceptedPromise = new Promise<Socket>((resolve) => server.once('connection', resolve));
|
||||
let client: Socket | null = null;
|
||||
let accepted: Socket | null = null;
|
||||
let readerFd: number | null = null;
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(socketPath, resolve);
|
||||
});
|
||||
client = createConnection(socketPath);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client!.once('connect', resolve);
|
||||
client!.once('error', reject);
|
||||
});
|
||||
accepted = await acceptedPromise;
|
||||
accepted.pause();
|
||||
readerFd = (accepted as any)._handle.fd as number;
|
||||
const clientFd = (client as any)._handle.fd as number;
|
||||
|
||||
const packet = new Promise<{ bytes: Buffer; fd: number }>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
native.startFdReader(readerFd!, (bytes, fds, closed) => {
|
||||
chunks.push(bytes);
|
||||
if (fds.length > 0) resolve({ bytes: Buffer.concat(chunks), fd: fds[0]! });
|
||||
else if (closed) reject(new Error('socket closed before the descriptor arrived'));
|
||||
});
|
||||
client!.write(Buffer.from([0x57, 0x4f, 0x46, 0x44]));
|
||||
native.sendFd(clientFd, sourceFd);
|
||||
});
|
||||
const received = await packet;
|
||||
expect(received.bytes.includes(Buffer.from([0x57, 0x4f, 0x46, 0x44]))).toBe(true);
|
||||
expect(fstatSync(received.fd).size).toBe(Buffer.byteLength('dma-buf-fd'));
|
||||
closeSync(received.fd);
|
||||
} finally {
|
||||
if (readerFd !== null) native.stopFdReader(readerFd);
|
||||
accepted?.destroy();
|
||||
client?.destroy();
|
||||
server.close();
|
||||
closeSync(sourceFd);
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -6,15 +6,54 @@ import { contextBridge, ipcRenderer } from 'electron';
|
|||
import type {
|
||||
CompositorAPI,
|
||||
WoClientAPI,
|
||||
WoPortals,
|
||||
WindowMetadataEvent,
|
||||
FocusChangeEvent,
|
||||
WoWindow,
|
||||
DamageFramePayload,
|
||||
RendererDamageHelper,
|
||||
RendererDamageHelperOptions,
|
||||
PortalRequestEvent,
|
||||
DBusBus,
|
||||
DBusCallOptions,
|
||||
DBusPropertyOptions,
|
||||
DBusInterfaceOptions,
|
||||
ApplicationIcon,
|
||||
ApplicationInfo,
|
||||
SyscallDbusGetAllPropertiesResult,
|
||||
SyscallDbusListNamesResult,
|
||||
SyscallLaunchResult,
|
||||
SyscallXdgDirectoriesResult,
|
||||
WoAudio,
|
||||
WoAudioSource,
|
||||
WoAudioChunk,
|
||||
WoCapability,
|
||||
WoPermissionState,
|
||||
WoPerformanceStatus,
|
||||
WoMonitor,
|
||||
} from 'wo-types';
|
||||
import { createRendererDamageHelper } from './renderer-damage-helper.js';
|
||||
|
||||
// The resource approval document is an internal data URL. Expose only the
|
||||
// one-shot response bridge there; the main process validates its request ID
|
||||
// and token before it accepts a decision.
|
||||
try {
|
||||
const consentUrl = new URL(globalThis.location.href);
|
||||
if (consentUrl.protocol === 'data:' && consentUrl.hash === '#wo-resource-consent') {
|
||||
contextBridge.exposeInMainWorld('woResourceConsent', {
|
||||
respond: (requestId: string, token: string, decision: 'deny' | 'allow' | 'allow-all') =>
|
||||
ipcRenderer.invoke('wo:resource-consent-respond', requestId, token, decision),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// The bridge is absent from ordinary pages and unavailable if the URL is malformed.
|
||||
}
|
||||
|
||||
let monitorCache: WoMonitor[] = [];
|
||||
ipcRenderer.on('wo:monitors', (_event, monitors: WoMonitor[]) => {
|
||||
monitorCache = Array.isArray(monitors) ? monitors : [];
|
||||
});
|
||||
|
||||
const compositorAPI: CompositorAPI = {
|
||||
action: async (name: string, payload?: Record<string, unknown>) => {
|
||||
return await ipcRenderer.invoke('action', name, payload ?? {});
|
||||
|
|
@ -26,7 +65,6 @@ const compositorAPI: CompositorAPI = {
|
|||
reload: () => ipcRenderer.invoke('action', 'reload', {}),
|
||||
toggleDevTools: () => ipcRenderer.invoke('action', 'devtools', {}),
|
||||
|
||||
// New: Focus change notifications
|
||||
onFocusChange: (callback: (window: string, focused: boolean) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: FocusChangeEvent) =>
|
||||
callback(data.window, data.focused);
|
||||
|
|
@ -34,24 +72,75 @@ const compositorAPI: CompositorAPI = {
|
|||
return () => ipcRenderer.removeListener('wo:focus-change', handler);
|
||||
},
|
||||
|
||||
// New: Window metadata
|
||||
onWindowMetadata: (callback: (metadata: WindowMetadataEvent) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, metadata: WindowMetadataEvent) => callback(metadata);
|
||||
ipcRenderer.on('wo:window-metadata', handler);
|
||||
return () => ipcRenderer.removeListener('wo:window-metadata', handler);
|
||||
},
|
||||
|
||||
// New: Syscall API
|
||||
syscall: async (type: string, params: Record<string, unknown>) => {
|
||||
onMonitors: (callback: (monitors: WoMonitor[]) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, monitors: WoMonitor[]) => callback(monitors);
|
||||
ipcRenderer.on('wo:monitors', handler);
|
||||
if (monitorCache.length) callback(monitorCache);
|
||||
return () => ipcRenderer.removeListener('wo:monitors', handler);
|
||||
},
|
||||
|
||||
syscall: async (type: string, params?: any) => {
|
||||
return await ipcRenderer.invoke('wo:syscall', type, params);
|
||||
},
|
||||
|
||||
dbus: {
|
||||
call: (options: DBusCallOptions) => ipcRenderer.invoke('wo:syscall', 'dbus_call', options),
|
||||
getProperty: (options: DBusPropertyOptions) => ipcRenderer.invoke('wo:syscall', 'dbus_get_property', options),
|
||||
getAllProperties: (options: DBusInterfaceOptions) => ipcRenderer.invoke('wo:syscall', 'dbus_get_all_properties', options) as Promise<SyscallDbusGetAllPropertiesResult>,
|
||||
listNames: (bus: DBusBus = 'user') => ipcRenderer.invoke('wo:syscall', 'dbus_list_names', { bus }) as Promise<SyscallDbusListNamesResult>,
|
||||
},
|
||||
|
||||
desktop: {
|
||||
applications: {
|
||||
list: (options?: { locale?: string }) => ipcRenderer.invoke('wo:syscall', 'list_applications', options ?? {}) as Promise<ApplicationInfo[]>,
|
||||
launch: (desktopId: string, files?: string[]) => ipcRenderer.invoke('wo:syscall', 'launch_desktop', { desktopId, files }) as Promise<SyscallLaunchResult>,
|
||||
},
|
||||
},
|
||||
|
||||
icons: {
|
||||
resolve: (name: string, options?: { size?: number; theme?: string }) => ipcRenderer.invoke('wo:syscall', 'resolve_icon', { name, ...options }) as Promise<ApplicationIcon | null>,
|
||||
},
|
||||
|
||||
xdg: {
|
||||
directories: () => ipcRenderer.invoke('wo:syscall', 'xdg_directories', {}) as Promise<SyscallXdgDirectoriesResult>,
|
||||
},
|
||||
|
||||
audio: {
|
||||
sources: () => ipcRenderer.invoke('wo:syscall', 'audio_sources') as Promise<{ ok: boolean; sources: WoAudioSource[]; error?: string }>,
|
||||
startDesktopCapture: () => ipcRenderer.invoke('wo:syscall', 'audio_capture_start', {}),
|
||||
startCapture: (sourceId: string) => ipcRenderer.invoke('wo:syscall', 'audio_capture_start', { sourceId }),
|
||||
stopCapture: () => ipcRenderer.invoke('wo:syscall', 'audio_capture_stop', {}),
|
||||
onData: (callback) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, chunk: WoAudioChunk) => callback(chunk);
|
||||
ipcRenderer.on('wo:audio-data', handler);
|
||||
return () => ipcRenderer.removeListener('wo:audio-data', handler);
|
||||
},
|
||||
onState: (callback) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, state: { active: boolean; error?: string }) => callback(state);
|
||||
ipcRenderer.on('wo:audio-state', handler);
|
||||
return () => ipcRenderer.removeListener('wo:audio-state', handler);
|
||||
},
|
||||
} satisfies WoAudio,
|
||||
|
||||
portals: {
|
||||
onRequest: (callback) => subscribePortalRequests(callback),
|
||||
contextMenu: (options) => ipcRenderer.invoke('wo:portal-context-menu', options),
|
||||
respond: async (requestId, response) => ipcRenderer.invoke('wo:syscall', 'portal_respond', {
|
||||
requestId,
|
||||
...response,
|
||||
}),
|
||||
},
|
||||
|
||||
submitDamageFrame: async (payload: DamageFramePayload) => {
|
||||
return await ipcRenderer.invoke('wo:submit-damage-frame', payload);
|
||||
},
|
||||
|
||||
// Surface buffer: Wayland window pixel content from compositor
|
||||
// Legacy path for backward compatibility (full pixel copy via IPC)
|
||||
onSurfaceBuffer: (callback: (data: { name: string; width: number; height: number; stride: number; pixels: Buffer; damageRects?: Array<{x: number; y: number; width: number; height: number}> }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: { name: string; width: number; height: number; stride: number; pixels: Buffer; damageRects?: Array<{x: number; y: number; width: number; height: number}> }) =>
|
||||
callback(data);
|
||||
|
|
@ -59,9 +148,6 @@ const compositorAPI: CompositorAPI = {
|
|||
return () => ipcRenderer.removeListener('wo:surface-buffer', handler);
|
||||
},
|
||||
|
||||
// Zero-copy surface buffer via SharedArrayBuffer.
|
||||
// 'wo:surface-sab' delivers the SAB once per window (or on resize).
|
||||
// 'wo:surface-update' is a lightweight signal (no pixels) when new data is ready.
|
||||
onSurfaceSab: (callback: (data: { name: string; sab: SharedArrayBuffer; width: number; height: number; stride: number }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: { name: string; sab: SharedArrayBuffer; width: number; height: number; stride: number }) =>
|
||||
callback(data);
|
||||
|
|
@ -83,7 +169,6 @@ const compositorAPI: CompositorAPI = {
|
|||
);
|
||||
},
|
||||
|
||||
// Notification events from compositor
|
||||
onNotification: (callback: (data: { id?: string; title: string; body: string; icon?: string; timeout?: number; timestamp?: number }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: { id?: string; title: string; body: string; icon?: string; timeout?: number; timestamp?: number }) =>
|
||||
callback(data);
|
||||
|
|
@ -91,17 +176,30 @@ const compositorAPI: CompositorAPI = {
|
|||
return () => ipcRenderer.removeListener('wo:notification', handler);
|
||||
},
|
||||
|
||||
// Forward keyboard event from canvas to compositor (evdev keycode)
|
||||
forwardKeyboard: (windowName: string, evdevKey: number, pressed: boolean, time: number) => {
|
||||
ipcRenderer.send('wo:forward-keyboard', windowName, evdevKey, pressed, time);
|
||||
},
|
||||
|
||||
// Forward relative mouse motion
|
||||
forwardPointerMotion: (windowName: string, x: number, y: number) => {
|
||||
ipcRenderer.send('wo:forward-pointer', windowName, x, y);
|
||||
},
|
||||
|
||||
forwardPointerButton: (windowName: string, x: number, y: number, button: number, pressed: boolean, time: number) => {
|
||||
ipcRenderer.send('wo:forward-pointer-button', windowName, x, y, button, pressed, time);
|
||||
},
|
||||
|
||||
forwardPointerScroll: (windowName: string, x: number, y: number, dx: number, dy: number, time: number) => {
|
||||
ipcRenderer.send('wo:forward-pointer-scroll', windowName, x, y, dx, dy, time);
|
||||
},
|
||||
|
||||
forwardPointerLeave: (windowName: string) => {
|
||||
ipcRenderer.send('wo:forward-pointer-leave', windowName);
|
||||
},
|
||||
|
||||
forwardRelativePointer: (windowName: string, dx: number, dy: number) => {
|
||||
ipcRenderer.send('wo:forward-relative-pointer', windowName, dx, dy);
|
||||
},
|
||||
|
||||
// Pointer lock request from compositor
|
||||
onPointerLockRequest: (callback: (data: { window: string; lock: boolean }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: { window: string; lock: boolean }) =>
|
||||
callback(data);
|
||||
|
|
@ -109,7 +207,6 @@ const compositorAPI: CompositorAPI = {
|
|||
return () => ipcRenderer.removeListener('wo:pointer-lock-request', handler);
|
||||
},
|
||||
|
||||
// Environment variable updates from compositor (e.g. DISPLAY after XWayland ready)
|
||||
onEnvUpdate: (callback: (vars: Record<string, string>) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, vars: Record<string, string>) =>
|
||||
callback(vars);
|
||||
|
|
@ -117,22 +214,12 @@ const compositorAPI: CompositorAPI = {
|
|||
return () => ipcRenderer.removeListener('wo:env-update', handler);
|
||||
},
|
||||
|
||||
// Generic portal request event for client-provided popup/approval handlers
|
||||
onPortalRequest: (callback: (data: {
|
||||
requestId: string;
|
||||
payload: any;
|
||||
kind?: string;
|
||||
}) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: {
|
||||
requestId: string;
|
||||
payload: any;
|
||||
kind?: string;
|
||||
}) => callback(data);
|
||||
ipcRenderer.on('wo:portal-request', handler);
|
||||
return () => ipcRenderer.removeListener('wo:portal-request', handler);
|
||||
},
|
||||
}) => void) => subscribePortalRequests(callback),
|
||||
|
||||
// Screencopy capture event notifications
|
||||
onScreencopyEvent: (callback: (data: { active: boolean; clientCount: number }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: { active: boolean; clientCount: number }) =>
|
||||
callback(data);
|
||||
|
|
@ -141,6 +228,41 @@ const compositorAPI: CompositorAPI = {
|
|||
},
|
||||
};
|
||||
|
||||
function subscribePortalRequests(callback: Parameters<WoPortals['onRequest']>[0]) {
|
||||
portalRequestListeners.add(callback);
|
||||
for (const data of pendingPortalRequests.splice(0)) {
|
||||
callback(data);
|
||||
ipcRenderer.send('wo:portal-ui-delivered', data.requestId);
|
||||
}
|
||||
return () => portalRequestListeners.delete(callback);
|
||||
}
|
||||
|
||||
const portalRequestListeners = new Set<(data: PortalRequestEvent) => void>();
|
||||
const pendingPortalRequests: PortalRequestEvent[] = [];
|
||||
ipcRenderer.on('wo:portal-request', (_event, data: PortalRequestEvent) => {
|
||||
if (portalRequestListeners.size === 0) {
|
||||
pendingPortalRequests.push(data);
|
||||
return;
|
||||
}
|
||||
for (const callback of portalRequestListeners) callback(data);
|
||||
ipcRenderer.send('wo:portal-ui-delivered', data.requestId);
|
||||
});
|
||||
|
||||
// The desktop shell owns the system portal UI. Keep its consent response path
|
||||
// separate from website capability grants so portal prompts still work when a
|
||||
// site's Wo APIs have not been approved.
|
||||
if (process.argv.includes('--wo-system-portal-ui=1')) {
|
||||
// The main process holds portal requests until this preload has installed
|
||||
// its queue listener. React can subscribe after that without losing an
|
||||
// early xdg-desktop-portal request during shell startup.
|
||||
ipcRenderer.send('wo:portal-ui-ready');
|
||||
contextBridge.exposeInMainWorld('woPortalUI', {
|
||||
onRequest: (callback: Parameters<WoPortals['onRequest']>[0]) => subscribePortalRequests(callback),
|
||||
respond: (requestId: string, response: unknown) =>
|
||||
ipcRenderer.invoke('wo:portal-ui-respond', requestId, response),
|
||||
});
|
||||
}
|
||||
|
||||
const woClientAPI: WoClientAPI = {
|
||||
getWindows: async () => ipcRenderer.invoke('wo:get-windows') as Promise<WoWindow[]>,
|
||||
onWindows: (callback: (windows: WoWindow[]) => void) => {
|
||||
|
|
@ -151,9 +273,157 @@ const woClientAPI: WoClientAPI = {
|
|||
sendAction: (action: string) => {
|
||||
ipcRenderer.send('wo:keybind-action', action);
|
||||
},
|
||||
portals: compositorAPI.portals,
|
||||
dbus: compositorAPI.dbus,
|
||||
desktop: compositorAPI.desktop,
|
||||
icons: compositorAPI.icons,
|
||||
xdg: compositorAPI.xdg,
|
||||
audio: compositorAPI.audio,
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('compositor', compositorAPI);
|
||||
contextBridge.exposeInMainWorld('woClient', woClientAPI);
|
||||
function readCapabilityArgument(name: string): WoCapability[] {
|
||||
const prefix = `--wo-api-${name}=`;
|
||||
const argument = process.argv.find((value) => value.startsWith(prefix));
|
||||
if (!argument) return [];
|
||||
try {
|
||||
const value = JSON.parse(decodeURIComponent(argument.slice(prefix.length)));
|
||||
return Array.isArray(value) ? value.filter((item): item is WoCapability => typeof item === 'string') : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function capabilityPageMatches(): boolean {
|
||||
const argument = process.argv.find((value) => value.startsWith('--wo-api-page='));
|
||||
if (!argument) return false;
|
||||
try {
|
||||
const approved = new URL(decodeURIComponent(argument.slice('--wo-api-page='.length)));
|
||||
const current = new URL((globalThis as any).location.href);
|
||||
approved.hash = '';
|
||||
current.hash = '';
|
||||
return approved.href === current.href;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
const requestedCapabilities = readCapabilityArgument('requested');
|
||||
const grantedCapabilities = new Set(capabilityPageMatches() ? readCapabilityArgument('granted') : []);
|
||||
const permissions: WoPermissionState = Object.freeze({
|
||||
requested: Object.freeze([...requestedCapabilities]),
|
||||
granted: Object.freeze([...grantedCapabilities]),
|
||||
has: (capability: WoCapability) => grantedCapabilities.has(capability),
|
||||
});
|
||||
|
||||
const compositorExposure: Record<string, unknown> = { ...compositorAPI, permissions };
|
||||
const hide = (...keys: string[]) => keys.forEach((key) => { delete compositorExposure[key]; });
|
||||
if (!grantedCapabilities.has('window-management')) hide(
|
||||
'action', 'actionSync', 'onFocusChange', 'onWindowMetadata', 'onMonitors', 'forwardKeyboard',
|
||||
'forwardPointerMotion', 'forwardPointerButton', 'forwardPointerScroll', 'forwardPointerLeave',
|
||||
'forwardRelativePointer', 'onPointerLockRequest',
|
||||
);
|
||||
if (!grantedCapabilities.has('overlay-rendering')) hide(
|
||||
'createDamageHelper', 'submitDamageFrame', 'onSurfaceBuffer', 'onSurfaceSab', 'onSurfaceUpdate',
|
||||
);
|
||||
if (!grantedCapabilities.has('desktop-applications')) hide('desktop');
|
||||
if (!grantedCapabilities.has('dbus')) hide('dbus');
|
||||
if (!grantedCapabilities.has('xdg')) hide('icons', 'xdg', 'onEnvUpdate');
|
||||
if (!grantedCapabilities.has('portals')) hide('portals', 'onPortalRequest', 'onScreencopyEvent');
|
||||
if (!grantedCapabilities.has('audio-capture')) hide('audio');
|
||||
if (!grantedCapabilities.has('notifications')) hide('onNotification');
|
||||
if (grantedCapabilities.size > 0) {
|
||||
contextBridge.exposeInMainWorld('compositor', compositorExposure);
|
||||
const clientExposure: Record<string, unknown> = { ...woClientAPI, permissions };
|
||||
if (!grantedCapabilities.has('window-management')) {
|
||||
delete clientExposure.getWindows;
|
||||
delete clientExposure.onWindows;
|
||||
delete clientExposure.sendAction;
|
||||
}
|
||||
if (!grantedCapabilities.has('portals')) delete clientExposure.portals;
|
||||
if (!grantedCapabilities.has('dbus')) delete clientExposure.dbus;
|
||||
if (!grantedCapabilities.has('desktop-applications')) delete clientExposure.desktop;
|
||||
if (!grantedCapabilities.has('xdg')) delete clientExposure.icons;
|
||||
if (!grantedCapabilities.has('xdg')) delete clientExposure.xdg;
|
||||
if (!grantedCapabilities.has('audio-capture')) delete clientExposure.audio;
|
||||
contextBridge.exposeInMainWorld('woClient', clientExposure);
|
||||
|
||||
const woExposure: Record<string, unknown> = {
|
||||
permissions,
|
||||
getPerformanceStatus: () => ipcRenderer.invoke('wo:performance-status') as Promise<WoPerformanceStatus>,
|
||||
onPerformanceStatus: (callback: (status: WoPerformanceStatus) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, status: WoPerformanceStatus) => callback(status);
|
||||
ipcRenderer.on('wo:performance-status', handler);
|
||||
return () => ipcRenderer.removeListener('wo:performance-status', handler);
|
||||
},
|
||||
};
|
||||
if (grantedCapabilities.has('window-management')) {
|
||||
woExposure.windows = {
|
||||
get: woClientAPI.getWindows,
|
||||
on: woClientAPI.onWindows,
|
||||
action: compositorAPI.action,
|
||||
onFocusChange: compositorAPI.onFocusChange,
|
||||
onMetadata: compositorAPI.onWindowMetadata,
|
||||
monitors: compositorAPI.onMonitors,
|
||||
};
|
||||
}
|
||||
if (grantedCapabilities.has('overlay-rendering')) {
|
||||
woExposure.overlay = {
|
||||
createDamageHelper: compositorAPI.createDamageHelper,
|
||||
submitDamageFrame: compositorAPI.submitDamageFrame,
|
||||
onSurfaceBuffer: compositorAPI.onSurfaceBuffer,
|
||||
onSurfaceSab: compositorAPI.onSurfaceSab,
|
||||
onSurfaceUpdate: compositorAPI.onSurfaceUpdate,
|
||||
mount: (element: HTMLElement, monitorId: string) => {
|
||||
const monitor = monitorCache.find((entry) => entry.id === monitorId);
|
||||
if (!monitor) throw new Error(`Unknown Wo monitor: ${monitorId}`);
|
||||
const style = element.style;
|
||||
const previous = {
|
||||
position: style.position,
|
||||
left: style.left,
|
||||
top: style.top,
|
||||
width: style.width,
|
||||
height: style.height,
|
||||
transform: style.transform,
|
||||
};
|
||||
const previousMonitor = element.dataset.woMonitor;
|
||||
style.position = 'absolute';
|
||||
style.left = `${monitor.x}px`;
|
||||
style.top = `${monitor.y}px`;
|
||||
style.width = `${monitor.width}px`;
|
||||
style.height = `${monitor.height}px`;
|
||||
style.transform = 'translateZ(0)';
|
||||
element.dataset.woMonitor = monitor.id;
|
||||
return () => {
|
||||
style.position = previous.position;
|
||||
style.left = previous.left;
|
||||
style.top = previous.top;
|
||||
style.width = previous.width;
|
||||
style.height = previous.height;
|
||||
style.transform = previous.transform;
|
||||
if (previousMonitor === undefined) delete element.dataset.woMonitor;
|
||||
else element.dataset.woMonitor = previousMonitor;
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (grantedCapabilities.has('desktop-applications')) woExposure.desktop = compositorAPI.desktop;
|
||||
if (grantedCapabilities.has('xdg')) {
|
||||
woExposure.xdg = compositorAPI.xdg;
|
||||
woExposure.icons = compositorAPI.icons;
|
||||
}
|
||||
if (grantedCapabilities.has('dbus')) woExposure.dbus = compositorAPI.dbus;
|
||||
if (grantedCapabilities.has('portals')) woExposure.portals = compositorAPI.portals;
|
||||
if (grantedCapabilities.has('audio-capture')) woExposure.audio = compositorAPI.audio;
|
||||
if (grantedCapabilities.has('notifications')) woExposure.onNotification = compositorAPI.onNotification;
|
||||
woExposure.syscall = compositorAPI.syscall;
|
||||
contextBridge.exposeInMainWorld('wo', woExposure);
|
||||
}
|
||||
if (grantedCapabilities.size === 0) {
|
||||
contextBridge.exposeInMainWorld('wo', {
|
||||
permissions,
|
||||
getPerformanceStatus: () => ipcRenderer.invoke('wo:performance-status') as Promise<WoPerformanceStatus>,
|
||||
onPerformanceStatus: (callback: (status: WoPerformanceStatus) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, status: WoPerformanceStatus) => callback(status);
|
||||
ipcRenderer.on('wo:performance-status', handler);
|
||||
return () => ipcRenderer.removeListener('wo:performance-status', handler);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type { CompositorAPI, WoClientAPI };
|
||||
|
|
|
|||
|
|
@ -11,27 +11,32 @@
|
|||
* IPC Protocol Magic Numbers (little-endian u32)
|
||||
*/
|
||||
export const MAGIC = {
|
||||
HELLO: 0x574F484C, // "WOHL"
|
||||
FRAME: 0x574F4652, // "WOFR"
|
||||
MOUSE_MOVE: 0x574F4D4D, // "WOMM"
|
||||
MOUSE_BUTTON: 0x574F4D42, // "WOMB"
|
||||
KEYBOARD: 0x574F4B42, // "WOKB"
|
||||
SCROLL: 0x574F5343, // "WOSC"
|
||||
ACTION: 0x574F4341, // "WOAC"
|
||||
FOCUS_CHANGE: 0x574F4643, // "WOFC"
|
||||
WINDOW_META: 0x574F574D, // "WOWM"
|
||||
WINDOW_POS: 0x574F5750, // "WOWP"
|
||||
SYSCALL: 0x574F5359, // "WOSY"
|
||||
FRAME_ACK: 0x574F4641, // "WOFA"
|
||||
SURFACE_BUFFER: 0x574F5342, // "WOSB" — Wayland surface pixels from compositor
|
||||
SHM_BUFFER: 0x574F534D, // "WOSM" — Wayland SHM surface sent to Electron via process FD
|
||||
DMABUF_FRAME: 0x574F4446, // "WODF" — Wayland surface DMABUF FDs from compositor
|
||||
FORWARD_POINTER: 0x574F5045, // "WOPE" — forwarded pointer from web UI canvas
|
||||
FORWARD_KEYBOARD: 0x574F4B45, // "WOKE" — forwarded keyboard from web UI canvas
|
||||
FORWARD_RELATIVE_POINTER: 0x574F5245, // "WORE" — forwarded relative pointer (dx/dy)
|
||||
POINTER_LOCK_REQUEST: 0x574F504C, // "WOPL" — server-to-client pointer lock request
|
||||
ENV_UPDATE: 0x574F4555, // "WOEU" — environment variable update broadcast
|
||||
SCREENCOPY_EVENT: 0x574F5345, // "WOSE" — screencopy capture event notification
|
||||
HELLO: 0x574F484C,
|
||||
FRAME: 0x574F4652,
|
||||
MOUSE_MOVE: 0x574F4D4D,
|
||||
MOUSE_BUTTON: 0x574F4D42,
|
||||
KEYBOARD: 0x574F4B42,
|
||||
SCROLL: 0x574F5343,
|
||||
ACTION: 0x574F4341,
|
||||
FOCUS_CHANGE: 0x574F4643,
|
||||
WINDOW_META: 0x574F574D,
|
||||
WINDOW_POS: 0x574F5750,
|
||||
SYSCALL: 0x574F5359,
|
||||
FRAME_ACK: 0x574F4641,
|
||||
SURFACE_BUFFER: 0x574F5342,
|
||||
SHM_BUFFER: 0x574F534D,
|
||||
DMABUF_FRAME: 0x574F4446,
|
||||
FORWARD_POINTER: 0x574F5045,
|
||||
FORWARD_KEYBOARD: 0x574F4B45,
|
||||
FORWARD_RELATIVE_POINTER: 0x574F5245,
|
||||
FORWARD_POINTER_BUTTON: 0x574F5042,
|
||||
FORWARD_POINTER_SCROLL: 0x574F5053,
|
||||
FORWARD_POINTER_LEAVE: 0x574F504E,
|
||||
POINTER_LOCK_REQUEST: 0x574F504C,
|
||||
ENV_UPDATE: 0x574F4555,
|
||||
SCREENCOPY_EVENT: 0x574F5345,
|
||||
WINDOW_SIZE: 0x574F525A,
|
||||
SITE_HASHES: 0x574F5348,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
|
@ -83,7 +88,7 @@ export interface FocusChangeEvent {
|
|||
|
||||
export interface WindowMetadataEvent {
|
||||
type: 'windowMetadata';
|
||||
metadata: string; // JSON string
|
||||
metadata: string;
|
||||
}
|
||||
|
||||
export interface PointerLockRequestEvent {
|
||||
|
|
@ -137,7 +142,7 @@ export interface FrameInfo {
|
|||
seq: number;
|
||||
width: number;
|
||||
height: number;
|
||||
format: string; // DRM fourcc as string
|
||||
format: string;
|
||||
planes: PlaneInfo[];
|
||||
}
|
||||
|
||||
|
|
@ -154,10 +159,10 @@ export interface DmabufFrameInfo {
|
|||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
format: number; // DRM fourcc as u32
|
||||
format: number;
|
||||
numPlanes: number;
|
||||
planes: DmabufPlaneInfo[];
|
||||
fds: number[]; // File descriptors passed with this message
|
||||
fds: number[];
|
||||
}
|
||||
|
||||
export interface DmabufPlaneInfo {
|
||||
|
|
@ -212,7 +217,6 @@ export function bufferToF64LE(buf: Buffer, offset: number = 0): number {
|
|||
|
||||
export function bufferToI32LE(buf: Buffer, offset: number = 0): number {
|
||||
const val = bufferToU32LE(buf, offset);
|
||||
// Convert unsigned to signed
|
||||
return val > 0x7fffffff ? val - 0x100000000 : val;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,9 +74,11 @@ export function createRendererDamageHelper(
|
|||
return;
|
||||
}
|
||||
const rowBytes = clamped.width * 4;
|
||||
const sourceX = clamped.x - rect.x;
|
||||
const sourceY = clamped.y - rect.y;
|
||||
const startX = clamped.x * 4;
|
||||
for (let row = 0; row < clamped.height; row += 1) {
|
||||
const srcStart = row * srcStride;
|
||||
const srcStart = (sourceY + row) * srcStride + sourceX * 4;
|
||||
const dstStart = (clamped.y + row) * stride + startX;
|
||||
frame.set(rgba.subarray(srcStart, srcStart + rowBytes), dstStart);
|
||||
}
|
||||
|
|
|
|||
106
electron/src/wo-capabilities.test.ts
Normal file
106
electron/src/wo-capabilities.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { prefetchWoSiteAssets, sha256, validateWoManifest } from './wo-capabilities.js';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of tempDirs.splice(0)) rmSync(path, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Wo site metadata', () => {
|
||||
test('requires the manifest to be tied to the exact index and origin', () => {
|
||||
const page = 'https://example.test/app/index.html';
|
||||
const indexSha256 = 'a'.repeat(64);
|
||||
const valid = {
|
||||
schemaVersion: 1,
|
||||
appId: 'org.example.app',
|
||||
origin: 'https://example.test',
|
||||
indexSha256,
|
||||
capabilities: ['window-management'],
|
||||
componentsCss: { url: './_wo.components.css', sha256: 'b'.repeat(64) },
|
||||
styles: [],
|
||||
scripts: [],
|
||||
};
|
||||
expect(validateWoManifest(valid, indexSha256, page)).not.toBeNull();
|
||||
expect(validateWoManifest(valid, 'c'.repeat(64), page)).toBeNull();
|
||||
expect(validateWoManifest({ ...valid, origin: 'https://attacker.test' }, indexSha256, page)).toBeNull();
|
||||
expect(validateWoManifest({ ...valid, capabilities: ['system-root'] }, indexSha256, page)).toBeNull();
|
||||
});
|
||||
|
||||
test('prefetches and hashes page assets before accepting capability requests', async () => {
|
||||
const page = 'https://example.test/app/index.html';
|
||||
const indexBytes = new TextEncoder().encode('<!doctype html><script src="./app.js"></script>');
|
||||
const cssBytes = new TextEncoder().encode(':root { --wo-accent: #abc; }');
|
||||
const appCssBytes = new TextEncoder().encode('body { color: white; }');
|
||||
const jsBytes = new TextEncoder().encode('window.appReady = true;');
|
||||
const indexSha256 = sha256(indexBytes);
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
appId: 'org.example.app',
|
||||
origin: 'https://example.test',
|
||||
indexSha256,
|
||||
capabilities: ['window-management'],
|
||||
componentsCss: { url: './_wo.components.css', sha256: sha256(cssBytes) },
|
||||
styles: [{ url: './assets/app.css', sha256: sha256(appCssBytes) }],
|
||||
scripts: [{ url: './app.js', sha256: sha256(jsBytes) }],
|
||||
};
|
||||
const assets = new Map([
|
||||
[page, indexBytes],
|
||||
['https://example.test/app/_wo.capabilities.json', new TextEncoder().encode(JSON.stringify(manifest))],
|
||||
['https://example.test/app/_wo.components.css', cssBytes],
|
||||
['https://example.test/app/assets/app.css', appCssBytes],
|
||||
['https://example.test/app/app.js', jsBytes],
|
||||
]);
|
||||
const fetcher = async (url: string) => {
|
||||
const bytes = assets.get(url);
|
||||
return { ok: !!bytes, arrayBuffer: async () => bytes!.buffer.slice(bytes!.byteOffset, bytes!.byteOffset + bytes!.byteLength) };
|
||||
};
|
||||
|
||||
const site = await prefetchWoSiteAssets(page, fetcher);
|
||||
expect(site.trusted).toBe(true);
|
||||
expect(site.capabilities).toEqual(['window-management']);
|
||||
expect(site.styles).toContain('--wo-accent');
|
||||
expect(site.styleBodies.map((style) => style.url)).toEqual(['https://example.test/app/assets/app.css']);
|
||||
expect(site.scripts).toEqual(['https://example.test/app/app.js']);
|
||||
|
||||
assets.set('https://example.test/app/bad.js', new TextEncoder().encode('changed script'));
|
||||
assets.set('https://example.test/app/_wo.capabilities.json', new TextEncoder().encode(JSON.stringify({
|
||||
...manifest,
|
||||
scripts: [{ url: './bad.js', sha256: sha256(jsBytes) }],
|
||||
})));
|
||||
const tampered = await prefetchWoSiteAssets(page, fetcher);
|
||||
expect(tampered.trusted).toBe(false);
|
||||
expect(tampered.capabilities).toEqual([]);
|
||||
});
|
||||
|
||||
test('loads an exact local build and its relative assets from file URLs', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'wo-site-'));
|
||||
tempDirs.push(root);
|
||||
const index = new TextEncoder().encode('<html><script type="module" src="./assets/app.js"></script></html>');
|
||||
const css = new TextEncoder().encode(':root { --wo-accent: red; }');
|
||||
const js = new TextEncoder().encode('export const app = true;');
|
||||
const assetsDir = join(root, 'assets');
|
||||
mkdirSync(assetsDir);
|
||||
writeFileSync(join(root, 'index.html'), index);
|
||||
writeFileSync(join(root, '_wo.components.css'), css);
|
||||
writeFileSync(join(assetsDir, 'app.js'), js);
|
||||
writeFileSync(join(root, '_wo.capabilities.json'), JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
appId: 'org.example.local',
|
||||
indexSha256: sha256(index),
|
||||
capabilities: ['overlay-rendering'],
|
||||
componentsCss: { url: './_wo.components.css', sha256: sha256(css) },
|
||||
scripts: [{ url: './assets/app.js', sha256: sha256(js) }],
|
||||
}));
|
||||
|
||||
const result = await prefetchWoSiteAssets(pathToFileURL(join(root, 'index.html')).href);
|
||||
expect(result.trusted).toBe(true);
|
||||
expect(result.appId).toBe('org.example.local');
|
||||
expect(result.scripts).toHaveLength(1);
|
||||
expect(result.styles).toContain('--wo-accent');
|
||||
});
|
||||
});
|
||||
182
electron/src/wo-capabilities.ts
Normal file
182
electron/src/wo-capabilities.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const WO_CAPABILITIES = [
|
||||
'window-management', 'overlay-rendering', 'desktop-applications', 'xdg', 'dbus', 'portals',
|
||||
'filesystem', 'process-launch', 'system-control', 'notifications', 'audio-capture',
|
||||
] as const;
|
||||
|
||||
export type WoCapability = typeof WO_CAPABILITIES[number];
|
||||
|
||||
export interface WoSiteManifest {
|
||||
schemaVersion: 1;
|
||||
appId: string;
|
||||
origin?: string;
|
||||
indexSha256: string;
|
||||
capabilities: WoCapability[];
|
||||
componentsCss: { url: string; sha256: string };
|
||||
styles?: Array<{ url: string; sha256: string }>;
|
||||
scripts: Array<{ url: string; sha256: string }>;
|
||||
}
|
||||
|
||||
export interface WoSiteAssets {
|
||||
pageUrl: string;
|
||||
appId: string;
|
||||
capabilities: WoCapability[];
|
||||
styles: string;
|
||||
stylesUrl: string;
|
||||
stylesHash: string;
|
||||
styleBodies: Array<{ url: string; hash: string; bytes: Uint8Array }>;
|
||||
scripts: string[];
|
||||
scriptBodies: Array<{ url: string; hash: string; bytes: Uint8Array }>;
|
||||
indexSha256: string;
|
||||
indexBytes: Uint8Array;
|
||||
stylesBytes: Uint8Array;
|
||||
trusted: boolean;
|
||||
trustError?: string;
|
||||
}
|
||||
|
||||
type FetchLike = (url: string) => Promise<{ ok: boolean; arrayBuffer(): Promise<ArrayBuffer> }>;
|
||||
const MAX_INDEX_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_ASSET_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
export function sha256(bytes: Uint8Array): string {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function isHash(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
export function validateWoManifest(value: unknown, indexHash: string, pageUrl: string): WoSiteManifest | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const m = value as Partial<WoSiteManifest>;
|
||||
let page: URL;
|
||||
try { page = new URL(pageUrl); } catch { return null; }
|
||||
if (m.schemaVersion !== 1 || typeof m.appId !== 'string' || !/^[a-zA-Z0-9._-]{1,128}$/.test(m.appId)) return null;
|
||||
if (m.indexSha256 !== indexHash || (m.origin !== undefined && m.origin !== page.origin)) return null;
|
||||
if (!Array.isArray(m.capabilities) || !m.capabilities.every((cap) => WO_CAPABILITIES.includes(cap))) return null;
|
||||
if (!m.componentsCss || typeof m.componentsCss.url !== 'string' || !isHash(m.componentsCss.sha256)) return null;
|
||||
if (!Array.isArray(m.scripts) || m.scripts.length > 256) return null;
|
||||
if (m.styles !== undefined && (!Array.isArray(m.styles) || m.styles.length > 256)) return null;
|
||||
const trustedPath = (path: string): boolean => {
|
||||
try {
|
||||
const resolved = new URL(path, page);
|
||||
return resolved.origin === page.origin && (resolved.protocol === 'https:' || resolved.protocol === 'http:' || resolved.protocol === 'file:');
|
||||
} catch { return false; }
|
||||
};
|
||||
if (!trustedPath(m.componentsCss.url)) return null;
|
||||
const seen = new Set<string>();
|
||||
for (const script of m.scripts) {
|
||||
if (!script || typeof script.url !== 'string' || !isHash(script.sha256) || !trustedPath(script.url)) return null;
|
||||
const absolute = new URL(script.url, page).href;
|
||||
if (seen.has(absolute)) return null;
|
||||
seen.add(absolute);
|
||||
}
|
||||
for (const style of m.styles ?? []) {
|
||||
if (!style || typeof style.url !== 'string' || !isHash(style.sha256) || !trustedPath(style.url)) return null;
|
||||
const absolute = new URL(style.url, page).href;
|
||||
if (seen.has(absolute)) return null;
|
||||
seen.add(absolute);
|
||||
}
|
||||
return m as WoSiteManifest;
|
||||
}
|
||||
|
||||
async function readUrl(url: string, maxBytes: number, fetcher: FetchLike): Promise<Uint8Array | null> {
|
||||
try {
|
||||
let bytes: Uint8Array;
|
||||
if (new URL(url).protocol === 'file:') {
|
||||
bytes = await readFile(fileURLToPath(url));
|
||||
} else {
|
||||
const response = await fetcher(url);
|
||||
if (!response.ok) return null;
|
||||
bytes = new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
return bytes.byteLength <= maxBytes ? bytes : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
const assetCache = new Map<string, Uint8Array>();
|
||||
|
||||
async function loadAsset(url: string, hash: string, maxBytes: number, fetcher: FetchLike): Promise<Uint8Array | null> {
|
||||
const cacheKey = `${url}#${hash}`;
|
||||
const cached = assetCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const bytes = await readUrl(url, maxBytes, fetcher);
|
||||
if (!bytes || sha256(bytes) !== hash) return null;
|
||||
assetCache.set(cacheKey, bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export async function prefetchWoSiteAssets(
|
||||
pageUrl: string,
|
||||
fetcher: FetchLike = async (url) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) return response;
|
||||
const parsed = new URL(url);
|
||||
if (parsed.hostname !== 'localhost' || parsed.protocol !== 'http:') return response;
|
||||
parsed.hostname = '127.0.0.1';
|
||||
return await fetch(parsed.href);
|
||||
} catch (error) {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.hostname !== 'localhost' || parsed.protocol !== 'http:') throw error;
|
||||
parsed.hostname = '127.0.0.1';
|
||||
return await fetch(parsed.href);
|
||||
}
|
||||
},
|
||||
): Promise<WoSiteAssets> {
|
||||
const empty = (trustError: string, indexSha256 = ''): WoSiteAssets => ({
|
||||
pageUrl, appId: 'website', capabilities: [], styles: '', stylesUrl: '', stylesHash: '', styleBodies: [], scripts: [], scriptBodies: [], indexSha256, indexBytes: new Uint8Array(), stylesBytes: new Uint8Array(), trusted: false, trustError,
|
||||
});
|
||||
let page: URL;
|
||||
try { page = new URL(pageUrl); } catch { return empty('invalid page URL'); }
|
||||
if (!['http:', 'https:', 'file:'].includes(page.protocol)) return empty(`unsupported page protocol: ${page.protocol}`);
|
||||
const index = await readUrl(page.href, MAX_INDEX_BYTES, fetcher);
|
||||
if (!index) return empty(`could not read page: ${page.href}`);
|
||||
const indexSha256 = sha256(index);
|
||||
const manifestUrl = new URL('_wo.capabilities.json', new URL('.', page)).href;
|
||||
const manifestBytes = await readUrl(manifestUrl, 256 * 1024, fetcher);
|
||||
if (!manifestBytes) return empty(`could not read capability manifest: ${manifestUrl}`, indexSha256);
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(new TextDecoder().decode(manifestBytes)); } catch { return empty('capability manifest is not valid JSON', indexSha256); }
|
||||
const manifest = validateWoManifest(parsed, indexSha256, page.href);
|
||||
if (!manifest) return empty('capability manifest does not match this page or contains invalid fields', indexSha256);
|
||||
|
||||
const cssUrl = new URL(manifest.componentsCss.url, page).href;
|
||||
const css = await loadAsset(cssUrl, manifest.componentsCss.sha256, 1024 * 1024, fetcher);
|
||||
if (!css) return empty(`component stylesheet failed SHA-256 verification: ${cssUrl}`, indexSha256);
|
||||
const scripts: string[] = [];
|
||||
const scriptBodies: Array<{ url: string; hash: string; bytes: Uint8Array }> = [];
|
||||
for (const script of manifest.scripts) {
|
||||
const scriptUrl = new URL(script.url, page).href;
|
||||
const bytes = await loadAsset(scriptUrl, script.sha256, MAX_ASSET_BYTES, fetcher);
|
||||
if (!bytes) return empty(`script failed SHA-256 verification: ${scriptUrl}`, indexSha256);
|
||||
scripts.push(scriptUrl);
|
||||
scriptBodies.push({ url: scriptUrl, hash: script.sha256, bytes });
|
||||
}
|
||||
const styleBodies: Array<{ url: string; hash: string; bytes: Uint8Array }> = [];
|
||||
for (const style of manifest.styles ?? []) {
|
||||
const styleUrl = new URL(style.url, page).href;
|
||||
const bytes = await loadAsset(styleUrl, style.sha256, MAX_ASSET_BYTES, fetcher);
|
||||
if (!bytes) return empty(`stylesheet failed SHA-256 verification: ${styleUrl}`, indexSha256);
|
||||
styleBodies.push({ url: styleUrl, hash: style.sha256, bytes });
|
||||
}
|
||||
return {
|
||||
pageUrl: page.href,
|
||||
appId: manifest.appId,
|
||||
capabilities: [...new Set(manifest.capabilities)],
|
||||
styles: new TextDecoder().decode(css),
|
||||
stylesUrl: cssUrl,
|
||||
stylesHash: manifest.componentsCss.sha256,
|
||||
styleBodies,
|
||||
scripts,
|
||||
scriptBodies,
|
||||
indexSha256,
|
||||
indexBytes: index,
|
||||
stylesBytes: css,
|
||||
trusted: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1,47 +1,10 @@
|
|||
/**
|
||||
* Wo Composer Input Event Receiver for Electron
|
||||
*
|
||||
* This module provides utilities for Electron apps to receive input events
|
||||
* from the Wo compositor via the IPC protocol.
|
||||
*
|
||||
* Usage in your Electron renderer process:
|
||||
*
|
||||
* const { WoInputReceiver } = require('./wo-input-receiver');
|
||||
* const receiver = new WoInputReceiver();
|
||||
*
|
||||
* receiver.on('mouseMove', (x, y) => {
|
||||
* console.log(`Mouse moved to: ${x}, ${y}`);
|
||||
* });
|
||||
*
|
||||
* receiver.on('mouseButton', (button, pressed, time) => {
|
||||
* console.log(`Button ${button} ${pressed ? 'pressed' : 'released'}`);
|
||||
* });
|
||||
*
|
||||
* receiver.on('keyboard', (key, pressed, time) => {
|
||||
* console.log(`Key ${key} ${pressed ? 'pressed' : 'released'}`);
|
||||
* });
|
||||
*
|
||||
* receiver.on('scroll', (vertical, horizontal, time) => {
|
||||
* console.log(`Scrolled V:${vertical} H:${horizontal}`);
|
||||
* });
|
||||
*
|
||||
* receiver.startListening();
|
||||
*
|
||||
* Or in the client app:
|
||||
*
|
||||
* // Access via window.__WO_INPUT_RECEIVER__
|
||||
* window.__WO_INPUT_RECEIVER__.on('mouseMove', (x, y) => {
|
||||
* // Handle mouse move
|
||||
* });
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import net from 'net';
|
||||
|
||||
const MAGIC_MOUSE_MOVE = 0x574F4D4D; // "WOMM"
|
||||
const MAGIC_MOUSE_BUTTON = 0x574F4D42; // "WOMB"
|
||||
const MAGIC_KEYBOARD = 0x574F4B42; // "WOKB"
|
||||
const MAGIC_SCROLL = 0x574F5343; // "WOSC"
|
||||
const MAGIC_MOUSE_MOVE = 0x574F4D4D;
|
||||
const MAGIC_MOUSE_BUTTON = 0x574F4D42;
|
||||
const MAGIC_KEYBOARD = 0x574F4B42;
|
||||
const MAGIC_SCROLL = 0x574F5343;
|
||||
|
||||
/**
|
||||
* Receives input events from the Wo compositor via IPC socket.
|
||||
|
|
@ -81,8 +44,6 @@ class WoInputReceiver extends EventEmitter {
|
|||
this.socket.destroy();
|
||||
}
|
||||
|
||||
// Create a new socket connection just for receiving input events
|
||||
// This reuses the DMABUF connection through parameter passing
|
||||
this.socket = net.createConnection(this.ipcSocket);
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
|
|
@ -119,8 +80,7 @@ class WoInputReceiver extends EventEmitter {
|
|||
let eventSize = 0;
|
||||
let parsed = false;
|
||||
|
||||
// Mouse move: 4 (magic) + 8 (x) + 8 (y) = 20 bytes
|
||||
if (magic === MAGIC_MOUSE_MOVE) { // "WOMM"
|
||||
if (magic === MAGIC_MOUSE_MOVE) {
|
||||
if (this.buffer.length >= 20) {
|
||||
const x = this.buffer.readDoubleLE(4);
|
||||
const y = this.buffer.readDoubleLE(12);
|
||||
|
|
@ -129,8 +89,7 @@ class WoInputReceiver extends EventEmitter {
|
|||
parsed = true;
|
||||
}
|
||||
}
|
||||
// Mouse button: 4 (magic) + 4 (button) + 4 (pressed) + 4 (time) = 16 bytes
|
||||
else if (magic === MAGIC_MOUSE_BUTTON) { // "WOMB"
|
||||
else if (magic === MAGIC_MOUSE_BUTTON) {
|
||||
if (this.buffer.length >= 16) {
|
||||
const button = this.buffer.readUInt32LE(4);
|
||||
const pressed = this.buffer.readUInt32LE(8) !== 0;
|
||||
|
|
@ -140,8 +99,7 @@ class WoInputReceiver extends EventEmitter {
|
|||
parsed = true;
|
||||
}
|
||||
}
|
||||
// Keyboard: 4 (magic) + 4 (key) + 4 (pressed) + 4 (time) = 16 bytes
|
||||
else if (magic === MAGIC_KEYBOARD) { // "WOKB"
|
||||
else if (magic === MAGIC_KEYBOARD) {
|
||||
if (this.buffer.length >= 16) {
|
||||
const key = this.buffer.readUInt32LE(4);
|
||||
const pressed = this.buffer.readUInt32LE(8) !== 0;
|
||||
|
|
@ -151,8 +109,7 @@ class WoInputReceiver extends EventEmitter {
|
|||
parsed = true;
|
||||
}
|
||||
}
|
||||
// Scroll: 4 (magic) + 4 (vertical) + 4 (horizontal) + 4 (time) = 16 bytes
|
||||
else if (magic === MAGIC_SCROLL) { // "WOSC"
|
||||
else if (magic === MAGIC_SCROLL) {
|
||||
if (this.buffer.length >= 16) {
|
||||
const vertical = this.buffer.readInt32LE(4);
|
||||
const horizontal = this.buffer.readInt32LE(8);
|
||||
|
|
@ -163,13 +120,11 @@ class WoInputReceiver extends EventEmitter {
|
|||
}
|
||||
}
|
||||
else {
|
||||
// Unknown magic, skip this byte and try again
|
||||
console.warn('[Wo] Unknown message magic: 0x' + magic.toString(16));
|
||||
this.buffer = this.buffer.slice(1);
|
||||
parsed = true;
|
||||
}
|
||||
|
||||
// If we couldn't parse a complete event, wait for more data
|
||||
if (!parsed) {
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
91
electron/src/xdg.test.ts
Normal file
91
electron/src/xdg.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { getXdgDirectories, listDesktopApplications, resolveDesktopIcon } from './xdg.js';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'wo-xdg-'));
|
||||
tempDirs.push(root);
|
||||
const home = join(root, 'home');
|
||||
const dataHome = join(home, '.local', 'share');
|
||||
const applications = join(dataHome, 'applications');
|
||||
const systemData = join(root, 'system-data');
|
||||
mkdirSync(applications, { recursive: true });
|
||||
mkdirSync(join(systemData, 'applications'), { recursive: true });
|
||||
return {
|
||||
root,
|
||||
home,
|
||||
applications,
|
||||
systemData,
|
||||
env: {
|
||||
HOME: home,
|
||||
XDG_DATA_HOME: dataHome,
|
||||
XDG_DATA_DIRS: systemData,
|
||||
XDG_CONFIG_HOME: join(home, '.config'),
|
||||
LANG: 'en_US.UTF-8',
|
||||
PATH: process.env.PATH,
|
||||
} as NodeJS.ProcessEnv,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('XDG desktop helpers', () => {
|
||||
test('reads XDG paths and user directories from the config file', () => {
|
||||
const f = fixture();
|
||||
mkdirSync(join(f.home, '.config'), { recursive: true });
|
||||
writeFileSync(join(f.home, '.config', 'user-dirs.dirs'), 'XDG_DOWNLOAD_DIR="$HOME/Inbox"\n');
|
||||
|
||||
const dirs = getXdgDirectories(f.env);
|
||||
expect(dirs.dataHome).toBe(join(f.home, '.local', 'share'));
|
||||
expect(dirs.dataDirs).toEqual([f.systemData]);
|
||||
expect(dirs.downloads).toBe(join(f.home, 'Inbox'));
|
||||
expect(dirs.runtimeDir).toBeNull();
|
||||
});
|
||||
|
||||
test('lists localized desktop entries and resolves themed icons', () => {
|
||||
const f = fixture();
|
||||
const iconFile = join(f.home, '.local', 'share', 'icons', 'hicolor', '64x64', 'apps', 'wo-test.png');
|
||||
mkdirSync(join(iconFile, '..'), { recursive: true });
|
||||
writeFileSync(iconFile, Buffer.from([1, 2, 3, 4]));
|
||||
writeFileSync(join(f.applications, 'wo-test.desktop'), [
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
'Name=Test App',
|
||||
'Name[en_US]=Localized Test App',
|
||||
'Exec=/usr/bin/env -- %F',
|
||||
'Icon=wo-test',
|
||||
'Categories=Utility;Development;',
|
||||
'Terminal=false',
|
||||
].join('\n'));
|
||||
writeFileSync(join(f.applications, 'hidden.desktop'), [
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
'Name=Hidden App',
|
||||
'Exec=/usr/bin/env',
|
||||
'Hidden=true',
|
||||
].join('\n'));
|
||||
|
||||
const apps = listDesktopApplications({ env: f.env });
|
||||
expect(apps).toHaveLength(1);
|
||||
expect(apps[0]).toMatchObject({
|
||||
name: 'Localized Test App',
|
||||
command: '/usr/bin/env',
|
||||
desktopId: 'wo-test.desktop',
|
||||
categories: ['Utility', 'Development'],
|
||||
terminal: false,
|
||||
});
|
||||
expect(apps[0].icon?.mimeType).toBe('image/png');
|
||||
expect(Buffer.from(apps[0].icon?.data || '', 'base64')).toEqual(Buffer.from([1, 2, 3, 4]));
|
||||
});
|
||||
|
||||
test('returns null for icons that are not present in active XDG icon trees', () => {
|
||||
const f = fixture();
|
||||
expect(resolveDesktopIcon('no-such-wo-icon', { env: f.env })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
292
electron/src/xdg.ts
Normal file
292
electron/src/xdg.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { basename, delimiter, extname, join, resolve } from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
export interface XdgDirectories {
|
||||
home: string;
|
||||
configHome: string;
|
||||
configDirs: string[];
|
||||
dataHome: string;
|
||||
dataDirs: string[];
|
||||
cacheHome: string;
|
||||
stateHome: string;
|
||||
runtimeDir: string | null;
|
||||
desktop: string;
|
||||
documents: string;
|
||||
downloads: string;
|
||||
music: string;
|
||||
pictures: string;
|
||||
publicShare: string;
|
||||
templates: string;
|
||||
videos: string;
|
||||
}
|
||||
|
||||
export interface DesktopApplication {
|
||||
name: string;
|
||||
command: string;
|
||||
desktopId: string;
|
||||
desktopFile: string;
|
||||
icon?: { type: 'base64'; data: string; mimeType: string };
|
||||
genericName?: string;
|
||||
comment?: string;
|
||||
categories?: string[];
|
||||
keywords?: string[];
|
||||
startupWMClass?: string;
|
||||
terminal?: boolean;
|
||||
startupNotify?: boolean;
|
||||
}
|
||||
|
||||
export function getXdgDirectories(env: NodeJS.ProcessEnv = process.env): XdgDirectories {
|
||||
const home = env.HOME || '/';
|
||||
const configHome = env.XDG_CONFIG_HOME || join(home, '.config');
|
||||
const dataHome = env.XDG_DATA_HOME || join(home, '.local', 'share');
|
||||
const dataDirs = splitDirs(env.XDG_DATA_DIRS || '/usr/local/share:/usr/share');
|
||||
const configDirs = splitDirs(env.XDG_CONFIG_DIRS || '/etc/xdg');
|
||||
const userDirsFile = join(configHome, 'user-dirs.dirs');
|
||||
const userDirs = parseUserDirs(userDirsFile, home);
|
||||
const userDir = (key: string, fallback: string) => userDirs[key] || join(home, fallback);
|
||||
|
||||
return {
|
||||
home,
|
||||
configHome,
|
||||
configDirs,
|
||||
dataHome,
|
||||
dataDirs,
|
||||
cacheHome: env.XDG_CACHE_HOME || join(home, '.cache'),
|
||||
stateHome: env.XDG_STATE_HOME || join(home, '.local', 'state'),
|
||||
runtimeDir: env.XDG_RUNTIME_DIR || null,
|
||||
desktop: userDir('XDG_DESKTOP_DIR', 'Desktop'),
|
||||
documents: userDir('XDG_DOCUMENTS_DIR', 'Documents'),
|
||||
downloads: userDir('XDG_DOWNLOAD_DIR', 'Downloads'),
|
||||
music: userDir('XDG_MUSIC_DIR', 'Music'),
|
||||
pictures: userDir('XDG_PICTURES_DIR', 'Pictures'),
|
||||
publicShare: userDir('XDG_PUBLICSHARE_DIR', 'Public'),
|
||||
templates: userDir('XDG_TEMPLATES_DIR', 'Templates'),
|
||||
videos: userDir('XDG_VIDEOS_DIR', 'Videos'),
|
||||
};
|
||||
}
|
||||
|
||||
function splitDirs(value: string): string[] {
|
||||
return [...new Set(value.split(delimiter).filter(Boolean).map((dir) => resolve(dir)))];
|
||||
}
|
||||
|
||||
function parseUserDirs(file: string, home: string): Record<string, string> {
|
||||
if (!existsSync(file)) return {};
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*(XDG_[A-Z]+_DIR)="((?:\\.|[^"])*)"\s*$/);
|
||||
if (!match) continue;
|
||||
const value = match[2].replace(/\\"/g, '"').replace(/\\\\/g, '\\').replace(/^\$HOME(?=\/|$)/, home);
|
||||
if (value.startsWith('/')) result[match[1]] = resolve(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function decodeDesktopValue(value: string): string {
|
||||
return value.replace(/\\([snrt\\])/g, (_match, code: string) => ({
|
||||
s: ' ', n: '\n', r: '\r', t: '\t', '\\': '\\',
|
||||
}[code] ?? code));
|
||||
}
|
||||
|
||||
function desktopKeyValues(content: string, locale: string): Map<string, string> {
|
||||
const values = new Map<string, string>();
|
||||
let inEntry = false;
|
||||
const localized = new Map<string, string>();
|
||||
const language = locale.split(/[.@]/)[0];
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('[')) {
|
||||
inEntry = trimmed === '[Desktop Entry]';
|
||||
continue;
|
||||
}
|
||||
if (!inEntry || !trimmed || trimmed.startsWith('#')) continue;
|
||||
const splitAt = trimmed.indexOf('=');
|
||||
if (splitAt < 1) continue;
|
||||
const key = trimmed.slice(0, splitAt);
|
||||
const value = decodeDesktopValue(trimmed.slice(splitAt + 1));
|
||||
const localizedKey = key.match(/^Name\[(.+)\]$/);
|
||||
if (localizedKey) localized.set(localizedKey[1], value);
|
||||
else values.set(key, value);
|
||||
}
|
||||
if (language && localized.has(language)) values.set('Name', localized.get(language)!);
|
||||
return values;
|
||||
}
|
||||
|
||||
function availableCommand(command: string): boolean {
|
||||
if (!command) return false;
|
||||
if (command.includes('/')) return existsSync(command);
|
||||
const result = spawnSync('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', 'wo', command], {
|
||||
stdio: 'ignore',
|
||||
env: process.env,
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
export function resolveDesktopIcon(
|
||||
name: string,
|
||||
options: { size?: number; theme?: string; env?: NodeJS.ProcessEnv } = {},
|
||||
): DesktopApplication['icon'] | undefined {
|
||||
const xdg = getXdgDirectories(options.env);
|
||||
const size = Math.max(16, options.size ?? 48);
|
||||
const theme = options.theme || options.env?.XDG_ICON_THEME || process.env.XDG_ICON_THEME || 'hicolor';
|
||||
const suffixes = extname(name) ? [''] : ['.png', '.svg'];
|
||||
const candidates: string[] = [];
|
||||
|
||||
if (name.startsWith('/')) candidates.push(name);
|
||||
for (const suffix of suffixes) {
|
||||
for (const root of [xdg.dataHome, ...xdg.dataDirs]) {
|
||||
for (const iconTheme of [theme, 'hicolor']) {
|
||||
const themeRoot = join(root, 'icons', iconTheme);
|
||||
for (const directory of iconSizeDirectories(themeRoot, size)) {
|
||||
candidates.push(join(themeRoot, directory, 'apps', `${name}${suffix}`));
|
||||
}
|
||||
candidates.push(join(themeRoot, 'apps', `${name}${suffix}`));
|
||||
}
|
||||
candidates.push(join(root, 'pixmaps', `${name}${suffix}`));
|
||||
}
|
||||
}
|
||||
|
||||
const file = candidates.find((candidate) => existsSync(candidate));
|
||||
if (!file) return undefined;
|
||||
const extension = extname(file).toLowerCase();
|
||||
const mimeType = extension === '.svg' ? 'image/svg+xml' : 'image/png';
|
||||
try {
|
||||
return { type: 'base64', data: readFileSync(file).toString('base64'), mimeType };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function iconSizeDirectories(themeRoot: string, target: number): string[] {
|
||||
try {
|
||||
return readdirSync(themeRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.filter((name) => /^(?:scalable|\d+x\d+(?:@\d+(?:\.\d+)?x)?)$/.test(name))
|
||||
.sort((a, b) => iconDirectoryDistance(a, target) - iconDirectoryDistance(b, target));
|
||||
} catch {
|
||||
return ['scalable', `${target}x${target}`, '48x48', '32x32', '24x24', '16x16'];
|
||||
}
|
||||
}
|
||||
|
||||
function iconDirectoryDistance(directory: string, target: number): number {
|
||||
if (directory === 'scalable') return 0;
|
||||
const match = directory.match(/^(\d+)x\d+/);
|
||||
return match ? Math.abs(Number(match[1]) - target) + 1 : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
export function listDesktopApplications(options: { env?: NodeJS.ProcessEnv; locale?: string } = {}): DesktopApplication[] {
|
||||
const env = options.env || process.env;
|
||||
const xdg = getXdgDirectories(env);
|
||||
const locale = options.locale || env.LC_ALL || env.LC_MESSAGES || env.LANG || 'C';
|
||||
const dirs = [join(xdg.dataHome, 'applications'), ...xdg.dataDirs.map((dir) => join(dir, 'applications'))];
|
||||
const seen = new Set<string>();
|
||||
const apps: DesktopApplication[] = [];
|
||||
|
||||
for (const dir of dirs) {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir).filter((entry) => entry.endsWith('.desktop')).sort();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const file = join(dir, entry);
|
||||
let keys: Map<string, string>;
|
||||
try {
|
||||
const content = readFileSync(file, 'utf8');
|
||||
if (content.length > 512_000) continue;
|
||||
keys = desktopKeyValues(content, locale);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const id = entry;
|
||||
if (seen.has(id) || keys.get('Type') !== 'Application') continue;
|
||||
seen.add(id);
|
||||
if (keys.get('Hidden') === 'true' || keys.get('NoDisplay') === 'true') continue;
|
||||
|
||||
const onlyShowIn = (keys.get('OnlyShowIn') || '').split(';').filter(Boolean);
|
||||
const notShowIn = (keys.get('NotShowIn') || '').split(';').filter(Boolean);
|
||||
const desktops = (env.XDG_CURRENT_DESKTOP || '').split(':').filter(Boolean);
|
||||
if (onlyShowIn.length && !onlyShowIn.some((desktop) => desktops.includes(desktop))) continue;
|
||||
if (notShowIn.some((desktop) => desktops.includes(desktop))) continue;
|
||||
|
||||
const execLine = keys.get('Exec') || '';
|
||||
const command = parseDesktopExec(execLine).argv[0] || '';
|
||||
if (!command || (keys.has('TryExec') && !availableCommand(keys.get('TryExec')!))) continue;
|
||||
const name = keys.get('Name') || basename(entry, '.desktop');
|
||||
const iconName = keys.get('Icon');
|
||||
const icon = iconName ? resolveDesktopIcon(iconName, { env }) : undefined;
|
||||
apps.push({
|
||||
name,
|
||||
command,
|
||||
desktopId: id,
|
||||
desktopFile: file,
|
||||
...(icon ? { icon } : {}),
|
||||
...(keys.has('GenericName') ? { genericName: keys.get('GenericName') } : {}),
|
||||
...(keys.has('Comment') ? { comment: keys.get('Comment') } : {}),
|
||||
...(keys.has('Categories') ? { categories: keys.get('Categories')!.split(';').filter(Boolean) } : {}),
|
||||
...(keys.has('Keywords') ? { keywords: keys.get('Keywords')!.split(';').filter(Boolean) } : {}),
|
||||
...(keys.has('StartupWMClass') ? { startupWMClass: keys.get('StartupWMClass') } : {}),
|
||||
...(keys.has('Terminal') ? { terminal: keys.get('Terminal') === 'true' } : {}),
|
||||
...(keys.has('StartupNotify') ? { startupNotify: keys.get('StartupNotify') === 'true' } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return apps;
|
||||
}
|
||||
|
||||
export async function launchDesktopApplication(
|
||||
desktopId: string,
|
||||
files: string[] = [],
|
||||
): Promise<{ ok: boolean; pid?: number; error?: string }> {
|
||||
const app = listDesktopApplications().find((entry) => entry.desktopId === desktopId || entry.name === desktopId);
|
||||
if (!app) return { ok: false, error: `Desktop application not found: ${desktopId}` };
|
||||
const keys = desktopKeyValues(readFileSync(app.desktopFile, 'utf8'), process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || 'C');
|
||||
const parsed = parseDesktopExec(keys.get('Exec') || '', { ...app, iconName: keys.get('Icon') }, files);
|
||||
const [command, ...args] = parsed.argv;
|
||||
if (!command) return { ok: false, error: `No executable in desktop entry: ${desktopId}` };
|
||||
|
||||
return new Promise((resolveResult) => {
|
||||
try {
|
||||
const child = spawn(command, args, { detached: true, stdio: 'ignore', env: process.env });
|
||||
child.once('error', (error) => resolveResult({ ok: false, error: error.message }));
|
||||
child.once('spawn', () => {
|
||||
child.unref();
|
||||
resolveResult({ ok: true, pid: child.pid });
|
||||
});
|
||||
} catch (error) {
|
||||
resolveResult({ ok: false, error: String(error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseDesktopExec(
|
||||
exec: string,
|
||||
app?: Pick<DesktopApplication, 'name' | 'desktopFile'> & { iconName?: string },
|
||||
files: string[] = [],
|
||||
): { argv: string[] } {
|
||||
const tokens = exec.match(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s]+/g) || [];
|
||||
const argv: string[] = [];
|
||||
for (const token of tokens) {
|
||||
const unquoted = token.replace(/^['"]|['"]$/g, '').replace(/\\(["'\\`$])/g, '$1');
|
||||
if (unquoted === '%i') {
|
||||
if (app?.iconName) argv.push('--icon', app.iconName);
|
||||
continue;
|
||||
}
|
||||
if (unquoted === '%F' || unquoted === '%U') {
|
||||
argv.push(...files);
|
||||
continue;
|
||||
}
|
||||
const value = unquoted.replace(/%%|%[fFuUdDnNickvm]/g, (code) => {
|
||||
if (code === '%%') return '%';
|
||||
if (code === '%c') return app?.name || '';
|
||||
if (code === '%k') return app?.desktopFile || '';
|
||||
if (code === '%f' || code === '%u') return files[0] || '';
|
||||
return '';
|
||||
});
|
||||
if (value) argv.push(value);
|
||||
}
|
||||
return { argv };
|
||||
}
|
||||
|
|
@ -2,7 +2,26 @@ export type {
|
|||
WindowsCallback,
|
||||
WoClient,
|
||||
WoClientAPI,
|
||||
WoCapability,
|
||||
WoPermissionState,
|
||||
WoGlobalAPI,
|
||||
WoPerformanceStatus,
|
||||
WoPortals,
|
||||
WoDBus,
|
||||
WoDesktop,
|
||||
WoIcons,
|
||||
WoXdg,
|
||||
WoAudio,
|
||||
WoAudioSource,
|
||||
WoAudioChunk,
|
||||
XdgDirectories,
|
||||
DBusBus,
|
||||
DBusCallOptions,
|
||||
DBusPropertyOptions,
|
||||
DBusInterfaceOptions,
|
||||
DBusResult,
|
||||
WoWindow,
|
||||
WoMonitor,
|
||||
ApplicationIcon,
|
||||
ApplicationInfo,
|
||||
CompositorActionConfig,
|
||||
|
|
@ -23,6 +42,21 @@ export type {
|
|||
RendererDamageHelperOptions,
|
||||
PortalRequestEvent,
|
||||
ScreencopyEvent,
|
||||
SyscallXdgDirectoriesResult,
|
||||
SyscallListApplicationsParams,
|
||||
SyscallLaunchDesktopParams,
|
||||
SyscallLaunchResult,
|
||||
SyscallDbusGetAllPropertiesParams,
|
||||
SyscallDbusCallParams,
|
||||
SyscallDbusCallResult,
|
||||
SyscallDbusGetPropertyParams,
|
||||
SyscallDbusGetPropertyResult,
|
||||
SyscallDbusListNamesParams,
|
||||
SyscallDbusGetAllPropertiesResult,
|
||||
SyscallDbusListNamesResult,
|
||||
SyscallAudioCaptureParams,
|
||||
SyscallAudioCaptureResult,
|
||||
SyscallResolveIconParams,
|
||||
} from './types';
|
||||
|
||||
export { InteractionDispatcher } from './dispatcher';
|
||||
|
|
|
|||
249
electron/types/types.d.ts
vendored
249
electron/types/types.d.ts
vendored
|
|
@ -6,61 +6,60 @@
|
|||
* Represents a single window managed by the compositor
|
||||
*/
|
||||
export interface WoWindow {
|
||||
/** Unique identifier for this window */
|
||||
name: string;
|
||||
|
||||
/** Window title/label */
|
||||
title?: string;
|
||||
|
||||
/** X coordinate of window in pixels */
|
||||
x: number;
|
||||
|
||||
/** Y coordinate of window in pixels */
|
||||
y: number;
|
||||
|
||||
/** Width of window in pixels */
|
||||
width: number;
|
||||
|
||||
/** Height of window in pixels */
|
||||
height: number;
|
||||
|
||||
/** Z-order (stacking) of the window (higher = on top) */
|
||||
content_width?: number;
|
||||
content_height?: number;
|
||||
|
||||
z_order?: number;
|
||||
|
||||
/** Whether this window is currently mapped/visible */
|
||||
mapped?: boolean;
|
||||
|
||||
/** Whether this window is currently focused */
|
||||
focused?: boolean;
|
||||
|
||||
/** Frames per second being rendered */
|
||||
fps?: number;
|
||||
|
||||
/** Process ID of the application (if available) */
|
||||
pid?: number;
|
||||
|
||||
/** Window source: 'electron', 'wayland', or 'x11' (XWayland clients). */
|
||||
source?: 'electron' | 'wayland' | 'x11';
|
||||
|
||||
/** True when server-side decorations/chrome should be drawn by comraw. */
|
||||
ssd?: boolean;
|
||||
|
||||
/** Application ID (for Wayland windows) */
|
||||
fullscreen?: boolean;
|
||||
|
||||
app_id?: string;
|
||||
|
||||
/** True if this is a dialog window (xdg_toplevel with set_parent) */
|
||||
dialog?: boolean;
|
||||
|
||||
/** Name of the parent window for dialog windows (e.g. "wayland-0") */
|
||||
parent_name?: string;
|
||||
}
|
||||
|
||||
export interface WoMonitor {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
primary: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action types for compositor window control
|
||||
*/
|
||||
export type CompositorActionType =
|
||||
| 'focus' | 'minimize' | 'close' | 'resize' | 'move' | 'maximize'
|
||||
| 'pointer_motion' | 'pointer_button' | 'pointer_leave' | 'pointer_scroll' | 'keyboard_key';
|
||||
| 'fullscreen' | 'pointer_motion' | 'pointer_button' | 'pointer_leave' | 'pointer_scroll' | 'keyboard_key';
|
||||
|
||||
/**
|
||||
* Configuration for compositor actions
|
||||
|
|
@ -74,9 +73,9 @@ export interface CompositorActionConfig {
|
|||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Stronger syscall typing
|
||||
|
||||
export interface SyscallListApplicationsParams { [key: string]: any; }
|
||||
export interface SyscallListApplicationsParams { locale?: string; }
|
||||
export interface SyscallLaunchDesktopParams { desktopId: string; files?: string[]; }
|
||||
export interface SyscallLaunchParams { command: string; [key: string]: any; }
|
||||
export interface SyscallExecParams { command: string; [key: string]: any; }
|
||||
export interface SyscallListDirParams { path?: string; [key: string]: any; }
|
||||
|
|
@ -91,11 +90,15 @@ export interface SyscallNotifyParams { id?: string; title: string; body?: string
|
|||
export interface SyscallPortalRespondParams { requestId: string; response?: any; allowed?: boolean; type?: string; windowName?: string | null; [key: string]: any; }
|
||||
export interface SyscallDbusCallParams { service: string; objectPath: string; interface: string; method: string; signature?: string; args?: any[]; bus?: 'user' | 'system'; [key: string]: any; }
|
||||
export interface SyscallDbusGetPropertyParams { service: string; objectPath: string; interface: string; property: string; bus?: 'user' | 'system'; [key: string]: any; }
|
||||
export interface SyscallDbusGetAllPropertiesParams { service: string; objectPath: string; interface: string; bus?: 'user' | 'system'; }
|
||||
export interface SyscallDbusListNamesParams { bus?: 'user' | 'system'; }
|
||||
export interface SyscallLoadLocalFileParams { path: string; [key: string]: any; }
|
||||
export interface SyscallResolveIconParams { name: string; size?: number; theme?: string; }
|
||||
|
||||
export interface SyscallLaunchResult { ok: boolean; pid?: number; error?: string; }
|
||||
export interface SyscallExecResult { ok: boolean; pid?: number; error?: string; }
|
||||
export interface SyscallListDirResult { ok: boolean; path?: string; entries?: { name: string; type: 'file'|'dir' }[]; error?: string; }
|
||||
export interface SyscallXdgDirectoriesResult { ok: boolean; directories?: XdgDirectories; error?: string; }
|
||||
export interface SyscallReadResult { ok: boolean; content?: string; size?: number; error?: string; }
|
||||
export interface SyscallWriteResult { ok: boolean; bytesWritten?: number; error?: string; }
|
||||
export interface SyscallShutdownResult { ok: boolean; error?: string; }
|
||||
|
|
@ -107,6 +110,10 @@ export interface SyscallNotifyResult { ok: boolean; error?: string; }
|
|||
export interface SyscallPortalRespondResult { ok: boolean; error?: string; }
|
||||
export interface SyscallDbusCallResult { ok: boolean; stdout?: string; stderr?: string; exitCode?: number; error?: string; }
|
||||
export interface SyscallDbusGetPropertyResult { ok: boolean; stdout?: string; stderr?: string; exitCode?: number; error?: string; }
|
||||
export interface SyscallDbusListNamesResult { ok: boolean; names?: string[]; stdout?: string; stderr?: string; exitCode?: number; error?: string; }
|
||||
export interface SyscallDbusGetAllPropertiesResult { ok: boolean; properties?: Record<string, string>; stdout?: string; stderr?: string; exitCode?: number; error?: string; }
|
||||
export interface SyscallAudioCaptureParams { sourceId?: string; }
|
||||
export interface SyscallAudioCaptureResult { ok: boolean; source?: WoAudioSource; error?: string; }
|
||||
export interface SyscallLoadLocalFileResult { ok: boolean; error?: string; }
|
||||
|
||||
export interface PortalRequestEvent {
|
||||
|
|
@ -115,6 +122,49 @@ export interface PortalRequestEvent {
|
|||
kind?: string;
|
||||
}
|
||||
|
||||
export type WoCapability =
|
||||
| 'window-management' | 'overlay-rendering' | 'desktop-applications' | 'xdg' | 'dbus'
|
||||
| 'portals' | 'filesystem' | 'process-launch' | 'system-control' | 'notifications' | 'audio-capture';
|
||||
|
||||
export interface WoPermissionState {
|
||||
requested: readonly WoCapability[];
|
||||
granted: readonly WoCapability[];
|
||||
has(capability: WoCapability): boolean;
|
||||
}
|
||||
|
||||
export interface WoPerformanceStatus {
|
||||
limited: boolean;
|
||||
gpuCompositing: string;
|
||||
webgl: string;
|
||||
sharedDmabuf: boolean | null;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface WoGlobalAPI {
|
||||
permissions: WoPermissionState;
|
||||
getPerformanceStatus(): Promise<WoPerformanceStatus>;
|
||||
onPerformanceStatus(callback: (status: WoPerformanceStatus) => void): Unsubscribe;
|
||||
windows?: {
|
||||
get(): Promise<WoWindow[]>;
|
||||
on(callback: WindowsCallback): Unsubscribe;
|
||||
action: Compositor['action'];
|
||||
onFocusChange: Compositor['onFocusChange'];
|
||||
onMetadata: Compositor['onWindowMetadata'];
|
||||
monitors: Compositor['onMonitors'];
|
||||
};
|
||||
overlay?: Pick<Compositor, 'createDamageHelper' | 'submitDamageFrame' | 'onSurfaceBuffer' | 'onSurfaceSab' | 'onSurfaceUpdate'> & {
|
||||
mount(element: HTMLElement, monitorId: string): Unsubscribe;
|
||||
};
|
||||
desktop?: WoDesktop;
|
||||
xdg?: WoXdg;
|
||||
icons?: WoIcons;
|
||||
dbus?: WoDBus;
|
||||
portals?: WoPortals;
|
||||
audio?: WoAudio;
|
||||
syscall?: Compositor['syscall'];
|
||||
onNotification?: Compositor['onNotification'];
|
||||
}
|
||||
|
||||
export interface ScreencopyEvent {
|
||||
active: boolean;
|
||||
clientCount: number;
|
||||
|
|
@ -128,19 +178,113 @@ export interface ApplicationInfo {
|
|||
command: string;
|
||||
multi_instance?: boolean;
|
||||
icon?: ApplicationIcon;
|
||||
desktopId?: string;
|
||||
desktopFile?: string;
|
||||
genericName?: string;
|
||||
comment?: string;
|
||||
categories?: string[];
|
||||
keywords?: string[];
|
||||
startupWMClass?: string;
|
||||
terminal?: boolean;
|
||||
startupNotify?: boolean;
|
||||
}
|
||||
|
||||
export interface XdgDirectories {
|
||||
home: string;
|
||||
configHome: string;
|
||||
configDirs: string[];
|
||||
dataHome: string;
|
||||
dataDirs: string[];
|
||||
cacheHome: string;
|
||||
stateHome: string;
|
||||
runtimeDir: string | null;
|
||||
desktop: string;
|
||||
documents: string;
|
||||
downloads: string;
|
||||
music: string;
|
||||
pictures: string;
|
||||
publicShare: string;
|
||||
templates: string;
|
||||
videos: string;
|
||||
}
|
||||
|
||||
export type DBusBus = 'user' | 'system';
|
||||
export interface DBusCallOptions {
|
||||
service: string;
|
||||
objectPath: string;
|
||||
interface: string;
|
||||
method: string;
|
||||
signature?: string;
|
||||
args?: Array<string | number | boolean>;
|
||||
bus?: DBusBus;
|
||||
}
|
||||
export interface DBusPropertyOptions {
|
||||
service: string;
|
||||
objectPath: string;
|
||||
interface: string;
|
||||
property: string;
|
||||
bus?: DBusBus;
|
||||
}
|
||||
export interface DBusInterfaceOptions {
|
||||
service: string;
|
||||
objectPath: string;
|
||||
interface: string;
|
||||
bus?: DBusBus;
|
||||
}
|
||||
export interface DBusResult {
|
||||
ok: boolean;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
exitCode?: number;
|
||||
error?: string;
|
||||
}
|
||||
export interface WoDBus {
|
||||
call(options: DBusCallOptions): Promise<DBusResult>;
|
||||
getProperty(options: DBusPropertyOptions): Promise<DBusResult>;
|
||||
getAllProperties(options: DBusInterfaceOptions): Promise<SyscallDbusGetAllPropertiesResult>;
|
||||
listNames(bus?: DBusBus): Promise<SyscallDbusListNamesResult>;
|
||||
}
|
||||
export interface WoDesktop {
|
||||
applications: {
|
||||
list(options?: SyscallListApplicationsParams): Promise<ApplicationInfo[]>;
|
||||
launch(desktopId: string, files?: string[]): Promise<SyscallLaunchResult>;
|
||||
};
|
||||
}
|
||||
export interface WoIcons {
|
||||
resolve(name: string, options?: { size?: number; theme?: string }): Promise<ApplicationIcon | null>;
|
||||
}
|
||||
export interface WoXdg {
|
||||
directories(): Promise<SyscallXdgDirectoriesResult>;
|
||||
}
|
||||
export interface WoAudioSource {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
monitor: boolean;
|
||||
}
|
||||
export interface WoAudioChunk {
|
||||
format: 's16le';
|
||||
sampleRate: 48000;
|
||||
channels: 2;
|
||||
data: Uint8Array;
|
||||
timestamp: number;
|
||||
}
|
||||
export interface WoAudio {
|
||||
sources(): Promise<{ ok: boolean; sources: WoAudioSource[]; error?: string }>;
|
||||
startDesktopCapture(): Promise<{ ok: boolean; source?: WoAudioSource; error?: string }>;
|
||||
startCapture(sourceId: string): Promise<{ ok: boolean; source?: WoAudioSource; error?: string }>;
|
||||
stopCapture(): Promise<{ ok: boolean; error?: string }>;
|
||||
onData(callback: (chunk: WoAudioChunk) => void): Unsubscribe;
|
||||
onState(callback: (state: { active: boolean; error?: string }) => void): Unsubscribe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon data for applications - supports multiple formats
|
||||
*/
|
||||
export interface ApplicationIcon {
|
||||
/** Type of icon: 'iconify', 'base64', 'url', or 'path' */
|
||||
type: 'iconify' | 'base64' | 'url' | 'path';
|
||||
/** Icon data or reference */
|
||||
data: string;
|
||||
/** Optional MIME type (e.g., 'image/png', 'image/svg+xml') */
|
||||
mimeType?: string;
|
||||
/** Optional fallback icon if primary fails */
|
||||
fallback?: ApplicationIcon;
|
||||
}
|
||||
|
||||
|
|
@ -168,6 +312,7 @@ export type Unsubscribe = () => void;
|
|||
* Wayland client interface for receiving window updates
|
||||
*/
|
||||
export interface WoClient {
|
||||
permissions?: WoPermissionState;
|
||||
/**
|
||||
* Get the current window list once
|
||||
*/
|
||||
|
|
@ -187,15 +332,47 @@ export interface WoClient {
|
|||
* Send a keybind/management action to the compositor
|
||||
*/
|
||||
sendAction(action: string): void;
|
||||
|
||||
portals: WoPortals;
|
||||
dbus: WoDBus;
|
||||
desktop: WoDesktop;
|
||||
icons: WoIcons;
|
||||
xdg: WoXdg;
|
||||
audio: WoAudio;
|
||||
}
|
||||
|
||||
export interface WoPortals {
|
||||
onRequest(callback: (data: PortalRequestEvent) => void): Unsubscribe;
|
||||
/** Open a native desktop context menu through the platform popup path. */
|
||||
contextMenu(options: WoContextMenuOptions): Promise<{ ok: boolean; selectedId?: string; error?: string }>;
|
||||
respond(requestId: string, response: {
|
||||
allowed: boolean;
|
||||
type?: 'window' | 'monitor' | 'virtual';
|
||||
windowName?: string | null;
|
||||
[key: string]: unknown;
|
||||
}): Promise<SyscallPortalRespondResult>;
|
||||
}
|
||||
|
||||
export interface WoContextMenuOptions {
|
||||
x?: number;
|
||||
y?: number;
|
||||
items: Array<{
|
||||
id?: string;
|
||||
label?: string;
|
||||
type?: 'normal' | 'separator' | 'checkbox' | 'radio';
|
||||
enabled?: boolean;
|
||||
checked?: boolean;
|
||||
accelerator?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Alias for WoClient to match preload.ts imports */
|
||||
export type WoClientAPI = WoClient;
|
||||
|
||||
/**
|
||||
* Compositor interface for controlling windows and the compositor
|
||||
*/
|
||||
export interface Compositor {
|
||||
permissions?: WoPermissionState;
|
||||
/**
|
||||
* Send an action to modify a window
|
||||
*/
|
||||
|
|
@ -210,6 +387,9 @@ export interface Compositor {
|
|||
* Execute a syscall (filesystem, process, etc.) with strong typings
|
||||
*/
|
||||
syscall(type: 'list_applications', params?: SyscallListApplicationsParams): Promise<ApplicationInfo[]>;
|
||||
syscall(type: 'launch_desktop', params: SyscallLaunchDesktopParams): Promise<SyscallLaunchResult>;
|
||||
syscall(type: 'xdg_directories', params?: Record<string, never>): Promise<SyscallXdgDirectoriesResult>;
|
||||
syscall(type: 'resolve_icon', params: SyscallResolveIconParams): Promise<ApplicationIcon | null>;
|
||||
syscall(type: 'launch', params: SyscallLaunchParams): Promise<SyscallLaunchResult>;
|
||||
syscall(type: 'exec', params: SyscallExecParams): Promise<SyscallExecResult>;
|
||||
syscall(type: 'listdir', params?: SyscallListDirParams): Promise<SyscallListDirResult>;
|
||||
|
|
@ -224,9 +404,22 @@ export interface Compositor {
|
|||
syscall(type: 'portal_respond', params: SyscallPortalRespondParams): Promise<SyscallPortalRespondResult>;
|
||||
syscall(type: 'dbus_call', params: SyscallDbusCallParams): Promise<SyscallDbusCallResult>;
|
||||
syscall(type: 'dbus_get_property', params: SyscallDbusGetPropertyParams): Promise<SyscallDbusGetPropertyResult>;
|
||||
syscall(type: 'dbus_get_all_properties', params: SyscallDbusGetAllPropertiesParams): Promise<SyscallDbusGetAllPropertiesResult>;
|
||||
syscall(type: 'dbus_list_names', params?: SyscallDbusListNamesParams): Promise<SyscallDbusListNamesResult>;
|
||||
syscall(type: 'audio_sources', params?: Record<string, never>): Promise<{ ok: boolean; sources: WoAudioSource[]; error?: string }>;
|
||||
syscall(type: 'audio_capture_start', params?: SyscallAudioCaptureParams): Promise<SyscallAudioCaptureResult>;
|
||||
syscall(type: 'audio_capture_stop', params?: Record<string, never>): Promise<{ ok: boolean; error?: string }>;
|
||||
syscall(type: 'load_local_file', params: SyscallLoadLocalFileParams): Promise<SyscallLoadLocalFileResult>;
|
||||
syscall(type: string, params?: any): Promise<any>;
|
||||
|
||||
/** XDG desktop entries, D-Bus and icon helpers. */
|
||||
dbus: WoDBus;
|
||||
portals: WoPortals;
|
||||
desktop: WoDesktop;
|
||||
icons: WoIcons;
|
||||
xdg: WoXdg;
|
||||
audio: WoAudio;
|
||||
|
||||
/**
|
||||
* Subscribe to focus changes
|
||||
*/
|
||||
|
|
@ -236,6 +429,7 @@ export interface Compositor {
|
|||
* Subscribe to raw window metadata broadcasts from the compositor
|
||||
*/
|
||||
onWindowMetadata(callback: (metadata: WindowMetadataEvent) => void): Unsubscribe;
|
||||
onMonitors(callback: (monitors: WoMonitor[]) => void): Unsubscribe;
|
||||
|
||||
/**
|
||||
* Create a damage helper for optimized rendering
|
||||
|
|
@ -248,6 +442,11 @@ export interface Compositor {
|
|||
*/
|
||||
forwardKeyboard(windowName: string, evdevKey: number, pressed: boolean, time: number): void;
|
||||
|
||||
forwardPointerMotion(windowName: string, x: number, y: number): void;
|
||||
forwardPointerButton(windowName: string, x: number, y: number, button: number, pressed: boolean, time: number): void;
|
||||
forwardPointerScroll(windowName: string, x: number, y: number, dx: number, dy: number, time: number): void;
|
||||
forwardPointerLeave(windowName: string): void;
|
||||
|
||||
/**
|
||||
* Quit the compositor
|
||||
*/
|
||||
|
|
@ -321,7 +520,6 @@ export interface Compositor {
|
|||
onSurfaceUpdate(callback: (data: SurfaceUpdateData) => void): Unsubscribe;
|
||||
}
|
||||
|
||||
/** Alias for Compositor to match preload.ts imports */
|
||||
export type CompositorAPI = Compositor;
|
||||
|
||||
/**
|
||||
|
|
@ -375,6 +573,7 @@ export interface NotificationData {
|
|||
declare global {
|
||||
window.woClient = {} as WoClient;
|
||||
window.compositor = {} as Compositor;
|
||||
window.wo = {} as WoGlobalAPI;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
127
justfile
Normal file
127
justfile
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
set shell := ["bash", "-eu", "-o", "pipefail", "-c"]
|
||||
|
||||
# List available build and development recipes.
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# Build the Rust executables in release mode.
|
||||
build-rust:
|
||||
cargo build --release --locked --bins
|
||||
|
||||
# Build the Electron application and its native addon.
|
||||
build-electron:
|
||||
cd electron && bun install --frozen-lockfile --ignore-scripts && bun run build && bun run build-native
|
||||
cd electron && if [ ! -x node_modules/electron/dist/electron ] && ! command -v electron >/dev/null 2>&1; then node node_modules/electron/install.js; fi
|
||||
cd electron && if [ ! -x node_modules/electron/dist/electron ] && ! command -v electron >/dev/null 2>&1; then echo "Electron runtime download failed; install Electron or retry with network access" >&2; exit 1; fi
|
||||
|
||||
# Install the local Wo types package before building comraw.
|
||||
build-types:
|
||||
cd electron/types && bun install --frozen-lockfile
|
||||
|
||||
# Build the compositor UI using the types shipped with this checkout.
|
||||
build-comraw:
|
||||
cd comraw && bun install --frozen-lockfile
|
||||
mkdir -p comraw/node_modules/@wl-wo
|
||||
rm -rf comraw/node_modules/@wl-wo/wo-types
|
||||
cp -a electron/types comraw/node_modules/@wl-wo/wo-types
|
||||
cd comraw && bun run build
|
||||
|
||||
# Build every runtime component in dependency order.
|
||||
build: build-rust build-electron build-types build-comraw
|
||||
|
||||
# Build and install Wo for the current user under ~/.local and XDG directories.
|
||||
# An existing user config is kept; a first install gets a config.toml derived
|
||||
# from config.example.toml and serves the packaged comraw build on localhost.
|
||||
install: build
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
repo_root="$(pwd)"
|
||||
: "${HOME:?HOME must be set for a per-user install}"
|
||||
xdg_data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||
data_home="$xdg_data_home/wo"
|
||||
config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/wo"
|
||||
user_bin="$HOME/.local/bin"
|
||||
electron_runtime="$data_home/electron-runtime"
|
||||
electron_app="$data_home/electron"
|
||||
comraw_dir="$data_home/comraw"
|
||||
electron_source="$repo_root/electron/node_modules/electron/dist/electron"
|
||||
install_tag="$(date +%Y%m%d%H%M%S).$$"
|
||||
electron_runtime_stage="$data_home/.electron-runtime.new.$install_tag"
|
||||
electron_app_stage="$data_home/.electron.new.$install_tag"
|
||||
comraw_stage="$data_home/.comraw.new.$install_tag"
|
||||
trap 'rm -rf "$electron_runtime_stage" "$electron_app_stage" "$comraw_stage"' EXIT
|
||||
|
||||
install -d "$data_home/bin" "$config_dir" "$user_bin"
|
||||
install -m755 "$repo_root/target/release/wo" "$data_home/bin/wo"
|
||||
install -m755 "$repo_root/target/release/wo-portal" "$data_home/bin/wo-portal"
|
||||
install -m755 "$repo_root/target/release/xdg-desktop-portal-wo" "$data_home/bin/xdg-desktop-portal-wo"
|
||||
if [ -x "$electron_source" ]; then
|
||||
install -d "$electron_runtime_stage"
|
||||
cp -a "$repo_root/electron/node_modules/electron/dist/." "$electron_runtime_stage/"
|
||||
test -x "$electron_runtime_stage/electron" || { echo "Electron runtime copy is incomplete" >&2; exit 1; }
|
||||
electron_bin="$electron_runtime/electron"
|
||||
elif command -v electron >/dev/null 2>&1; then
|
||||
electron_bin="$(command -v electron)"
|
||||
else
|
||||
echo "Electron runtime is missing. Install Electron or run: cd electron && node node_modules/electron/install.js" >&2
|
||||
exit 1
|
||||
fi
|
||||
install -d "$electron_app_stage/dist" "$electron_app_stage/native" "$comraw_stage"
|
||||
cp -a "$repo_root/electron/dist/." "$electron_app_stage/dist/"
|
||||
cp -a "$repo_root/electron/native/." "$electron_app_stage/native/"
|
||||
cp -a "$repo_root/comraw/dist/." "$comraw_stage/"
|
||||
|
||||
replace_installed_dir() {
|
||||
stage="$1"
|
||||
destination="$2"
|
||||
if [ -e "$destination" ] || [ -L "$destination" ]; then
|
||||
# Keep the prior tree available to processes that were already running.
|
||||
mv "$destination" "$destination.previous.$install_tag"
|
||||
fi
|
||||
mv "$stage" "$destination"
|
||||
}
|
||||
if [ -d "$electron_runtime_stage" ]; then
|
||||
replace_installed_dir "$electron_runtime_stage" "$electron_runtime"
|
||||
fi
|
||||
replace_installed_dir "$electron_app_stage" "$electron_app"
|
||||
replace_installed_dir "$comraw_stage" "$comraw_dir"
|
||||
|
||||
cat > "$user_bin/wo" <<EOF
|
||||
#!/bin/sh
|
||||
export WO_DATA_DIR="$data_home"
|
||||
export WO_ELECTRON_PATH="$electron_bin"
|
||||
export WO_ELECTRON_APP_DIR="$electron_app"
|
||||
exec "$data_home/bin/wo" "\$@"
|
||||
EOF
|
||||
chmod 755 "$user_bin/wo"
|
||||
install -m755 "$data_home/bin/wo-portal" "$user_bin/wo-portal"
|
||||
install -m755 "$data_home/bin/xdg-desktop-portal-wo" "$user_bin/xdg-desktop-portal-wo"
|
||||
|
||||
if [ ! -e "$config_dir/config.toml" ]; then
|
||||
cp "$repo_root/config.example.toml" "$config_dir/config.toml"
|
||||
fi
|
||||
# Existing user configuration is preserved byte-for-byte. Keep any
|
||||
# migrations explicit so installation cannot silently change launch
|
||||
# commands, display variables, URLs, or user preferences.
|
||||
|
||||
install -D -m644 "$repo_root/packaging/wayland-sessions/wo.desktop" "$xdg_data_home/wayland-sessions/wo.desktop"
|
||||
sed -i "s#Exec=wo#Exec=$user_bin/wo#; s#TryExec=wo#TryExec=$user_bin/wo#" "$xdg_data_home/wayland-sessions/wo.desktop"
|
||||
install -D -m644 "$repo_root/packaging/portals/wo.portal" "$xdg_data_home/xdg-desktop-portal/portals/wo.portal"
|
||||
install -D -m644 "$repo_root/packaging/dbus-services/org.freedesktop.impl.portal.desktop.wo.service" "$xdg_data_home/dbus-1/services/org.freedesktop.impl.portal.desktop.wo.service"
|
||||
sed -i "s#Exec=/usr/bin/xdg-desktop-portal-wo#Exec=$user_bin/xdg-desktop-portal-wo#" "$xdg_data_home/dbus-1/services/org.freedesktop.impl.portal.desktop.wo.service"
|
||||
echo "Installed Wo for the current user. Ensure $user_bin is on PATH and select Wo from your session manager."
|
||||
|
||||
# Run the Rust and Electron test suites.
|
||||
test:
|
||||
cargo test --locked
|
||||
cd electron && bun run test
|
||||
|
||||
# Build a Debian package for the current Ubuntu/Debian architecture.
|
||||
ubuntu-deb:
|
||||
packaging/ubuntu/build-deb.sh
|
||||
|
||||
# Check formatting and compile the Rust binaries without producing a release build.
|
||||
check:
|
||||
cargo fmt --all -- --check
|
||||
cargo check --locked --bins
|
||||
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
[D-BUS Service]
|
||||
Name=org.freedesktop.impl.portal.desktop.wo
|
||||
Exec=/usr/bin/wo-portal
|
||||
Exec=/usr/bin/xdg-desktop-portal-wo
|
||||
|
|
|
|||
23
packaging/engines/README.md
Normal file
23
packaging/engines/README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Upstream engine patches
|
||||
|
||||
Wo keeps its Electron integration patch against Electron `v45.0.0`
|
||||
(`54f961b44b83e4ece81065ab60a182fd0c38c01e`) in `wo-electron-v45.patch`.
|
||||
The CEF patch targets source commit
|
||||
`e537569ecda2e3f23ffdac559ecf7f80b8e59467` and is stored in `wo-cef.patch`.
|
||||
|
||||
The Electron patch removes the old 240 FPS offscreen ceiling; Wo supplies each
|
||||
Electron window's current output refresh rate to `webContents.setFrameRate()`.
|
||||
The CEF patch prevents the compositor interval from rounding to zero at high
|
||||
rates and documents updating the per-browser rate when a window changes
|
||||
displays. Both engines already expose shared/native-pixmap offscreen callbacks;
|
||||
Wo's native addon transfers their DMA-BUF descriptors using `SCM_RIGHTS`.
|
||||
|
||||
Apply both patches to their corresponding source checkouts with:
|
||||
|
||||
```sh
|
||||
packaging/engines/apply-wo-patches.sh /path/to/electron-v45 /path/to/cef
|
||||
```
|
||||
|
||||
The Electron and CEF repositories need their matching Chromium source and
|
||||
toolchains to build. These patches were reviewed against the checked-out source
|
||||
but a full Chromium/Electron/CEF build was not run.
|
||||
16
packaging/engines/apply-wo-patches.sh
Executable file
16
packaging/engines/apply-wo-patches.sh
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 2 ]]; then
|
||||
echo "Usage: $0 /path/to/electron-v45 /path/to/cef" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
electron_source="$1"
|
||||
cef_source="$2"
|
||||
|
||||
git -C "$electron_source" apply --check "$repo_root/packaging/engines/wo-electron-v45.patch"
|
||||
git -C "$cef_source" apply --check "$repo_root/packaging/engines/wo-cef.patch"
|
||||
git -C "$electron_source" apply "$repo_root/packaging/engines/wo-electron-v45.patch"
|
||||
git -C "$cef_source" apply "$repo_root/packaging/engines/wo-cef.patch"
|
||||
10
packaging/engines/cef-wo.gn
Normal file
10
packaging/engines/cef-wo.gn
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
is_debug = false
|
||||
is_official_build = true
|
||||
is_component_build = false
|
||||
symbol_level = 0
|
||||
blink_symbol_level = 0
|
||||
v8_symbol_level = 0
|
||||
enable_printing = false
|
||||
enable_pdf = false
|
||||
enable_pdf_viewer = false
|
||||
enable_spellcheck = false
|
||||
9
packaging/engines/electron-wo.gn
Normal file
9
packaging/engines/electron-wo.gn
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import("//electron/build/args/release.gn")
|
||||
|
||||
enable_printing = false
|
||||
enable_pdf = false
|
||||
enable_pdf_viewer = false
|
||||
enable_spellcheck = false
|
||||
symbol_level = 0
|
||||
blink_symbol_level = 0
|
||||
v8_symbol_level = 0
|
||||
55
packaging/engines/wo-cef.patch
Normal file
55
packaging/engines/wo-cef.patch
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
diff --git a/include/cef_browser.h b/include/cef_browser.h
|
||||
index 2d31eca..21f40eb 100644
|
||||
--- a/include/cef_browser.h
|
||||
+++ b/include/cef_browser.h
|
||||
@@ -814,8 +814,10 @@ class CefBrowserHost : public virtual CefBaseRefCounted {
|
||||
/// Set the maximum rate in frames per second (fps) that CefRenderHandler::
|
||||
/// OnPaint will be called for a windowless browser. The actual fps may be
|
||||
/// lower if the browser cannot generate frames at the requested rate. The
|
||||
- /// minimum value is 1 and the default value is 30. Can also be set at browser
|
||||
- /// creation via CefBrowserSettings.windowless_frame_rate.
|
||||
+ /// minimum value is 1 and the default value is 30. There is no fixed upper
|
||||
+ /// frame-rate cap; callers should provide the refresh rate of the display
|
||||
+ /// containing the window and update it when that rate changes. Can also be
|
||||
+ /// set at browser creation via CefBrowserSettings.windowless_frame_rate.
|
||||
///
|
||||
/*--cef()--*/
|
||||
virtual void SetWindowlessFrameRate(int frame_rate) = 0;
|
||||
diff --git a/include/internal/cef_types.h b/include/internal/cef_types.h
|
||||
index aef516c..fe916b4 100644
|
||||
--- a/include/internal/cef_types.h
|
||||
+++ b/include/internal/cef_types.h
|
||||
@@ -605,8 +605,10 @@ typedef struct _cef_browser_settings_t {
|
||||
/// The maximum rate in frames per second (fps) that CefRenderHandler::OnPaint
|
||||
/// will be called for a windowless browser. The actual fps may be lower if
|
||||
/// the browser cannot generate frames at the requested rate. The minimum
|
||||
- /// value is 1 and the default value is 30. This value can also be changed
|
||||
- /// dynamically via CefBrowserHost::SetWindowlessFrameRate.
|
||||
+ /// value is 1 and the default value is 30. Set a per-window display refresh
|
||||
+ /// rate and update it when that window moves to a display with a different
|
||||
+ /// refresh rate. This value can also be changed dynamically via
|
||||
+ /// CefBrowserHost::SetWindowlessFrameRate.
|
||||
///
|
||||
int windowless_frame_rate;
|
||||
|
||||
diff --git a/libcef/browser/osr/render_widget_host_view_osr.cc b/libcef/browser/osr/render_widget_host_view_osr.cc
|
||||
index 8135ee0..4e334ed 100644
|
||||
--- a/libcef/browser/osr/render_widget_host_view_osr.cc
|
||||
+++ b/libcef/browser/osr/render_widget_host_view_osr.cc
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
+#include <algorithm>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
@@ -1820,7 +1821,7 @@ void CefRenderWidgetHostViewOSR::SetFrameRate() {
|
||||
int frame_rate =
|
||||
osr_util::ClampFrameRate(browser->settings().windowless_frame_rate);
|
||||
|
||||
- frame_rate_threshold_us_ = 1000000 / frame_rate;
|
||||
+ frame_rate_threshold_us_ = std::max(1, 1000000 / frame_rate);
|
||||
|
||||
if (compositor_) {
|
||||
compositor_->SetDisplayVSyncParameters(
|
||||
30
packaging/engines/wo-electron-v45.patch
Normal file
30
packaging/engines/wo-electron-v45.patch
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
diff --git a/docs/api/web-contents.md b/docs/api/web-contents.md
|
||||
index 935ded4..7b68e69 100644
|
||||
--- a/docs/api/web-contents.md
|
||||
+++ b/docs/api/web-contents.md
|
||||
@@ -2263,7 +2263,10 @@ Returns `boolean` - If _offscreen rendering_ is enabled returns whether it is cu
|
||||
* `fps` Integer
|
||||
|
||||
If _offscreen rendering_ is enabled sets the frame rate to the specified number.
|
||||
-When `webPreferences.offscreen.useSharedTexture` is `false` only values between 1 and 240 are accepted.
|
||||
+The requested rate is not globally capped. Applications should set it to the
|
||||
+refresh rate of the display containing the window and update it when that
|
||||
+display's refresh rate changes. The actual rate may be lower when the renderer
|
||||
+cannot produce frames quickly enough.
|
||||
|
||||
#### `contents.getFrameRate()`
|
||||
|
||||
diff --git a/shell/browser/osr/osr_render_widget_host_view.cc b/shell/browser/osr/osr_render_widget_host_view.cc
|
||||
index dff1fcc..6536102 100644
|
||||
--- a/shell/browser/osr/osr_render_widget_host_view.cc
|
||||
+++ b/shell/browser/osr/osr_render_widget_host_view.cc
|
||||
@@ -899,9 +899,6 @@ void OffScreenRenderWidgetHostView::SetFrameRate(int frame_rate) {
|
||||
} else {
|
||||
if (frame_rate <= 0)
|
||||
frame_rate = 1;
|
||||
- if (!offscreen_use_shared_texture_ && frame_rate > 240)
|
||||
- frame_rate = 240;
|
||||
-
|
||||
frame_rate_ = frame_rate;
|
||||
}
|
||||
|
||||
22
packaging/ubuntu/README.md
Normal file
22
packaging/ubuntu/README.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Ubuntu package
|
||||
|
||||
Build an architecture-native `.deb` from the checkout with:
|
||||
|
||||
```sh
|
||||
packaging/ubuntu/build-deb.sh
|
||||
```
|
||||
|
||||
The script builds the Wo compositor and portal binaries, builds the Electron
|
||||
application and its DMA-BUF addon, builds `comraw`, and stages the Electron
|
||||
runtime with the package. It writes the package to `target/ubuntu-package/`.
|
||||
Set `WO_PACKAGE_VERSION` to override the version or `WO_DEB_BUILD_ROOT` to
|
||||
choose another output directory.
|
||||
|
||||
Build dependencies are Rust/Cargo, Bun, a C/C++ toolchain, `dpkg-dev`, and the
|
||||
development headers for libinput, libudev, libseat, libdrm, GBM, EGL, Wayland,
|
||||
PipeWire, and XKBCommon. The build also needs network access for locked Bun
|
||||
dependencies and Electron's Linux runtime archive.
|
||||
|
||||
Install the result with `sudo apt install ./target/ubuntu-package/wo_*.deb`.
|
||||
The package installs the Wo Wayland session, its xdg-desktop-portal
|
||||
implementation, and a launcher that selects the bundled Electron runtime.
|
||||
74
packaging/ubuntu/build-deb.sh
Executable file
74
packaging/ubuntu/build-deb.sh
Executable file
|
|
@ -0,0 +1,74 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
package_version="${WO_PACKAGE_VERSION:-$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$repo_root/Cargo.toml" | head -n1)}"
|
||||
package_arch="$(dpkg --print-architecture)"
|
||||
build_root="${WO_DEB_BUILD_ROOT:-$repo_root/target/ubuntu-package}"
|
||||
stage="$build_root/root"
|
||||
deb="$build_root/wo_${package_version}_${package_arch}.deb"
|
||||
|
||||
for tool in cargo bun dpkg dpkg-deb; do
|
||||
command -v "$tool" >/dev/null 2>&1 || {
|
||||
echo "Missing build tool: $tool" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
if [[ ! -d "$repo_root/comraw" ]]; then
|
||||
echo "Missing comraw source tree at $repo_root/comraw" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building Wo release binaries"
|
||||
(cd "$repo_root" && cargo build --release --locked --bins)
|
||||
|
||||
echo "Building Electron app and native DMA-BUF addon"
|
||||
(cd "$repo_root/electron" && bun install --frozen-lockfile && bun run build && bun run build-native)
|
||||
|
||||
echo "Building comraw"
|
||||
(cd "$repo_root/comraw" && bun install --frozen-lockfile)
|
||||
rm -rf "$repo_root/comraw/node_modules/@wl-wo/wo-types"
|
||||
mkdir -p "$repo_root/comraw/node_modules/@wl-wo"
|
||||
cp -a "$repo_root/electron/types" "$repo_root/comraw/node_modules/@wl-wo/wo-types"
|
||||
(cd "$repo_root/comraw" && bun run build)
|
||||
|
||||
rm -rf "$stage"
|
||||
install -d "$stage/DEBIAN" "$stage/usr/bin" "$stage/usr/lib/wo/bin" \
|
||||
"$stage/usr/lib/wo/electron" "$stage/usr/lib/wo/electron-runtime" \
|
||||
"$stage/usr/lib/wo/comraw" "$stage/usr/share/wayland-sessions" \
|
||||
"$stage/usr/share/xdg-desktop-portal/portals" "$stage/usr/share/dbus-1/services" \
|
||||
"$stage/usr/share/doc/wo"
|
||||
|
||||
install -m755 "$repo_root/target/release/wo" "$stage/usr/lib/wo/bin/wo"
|
||||
install -m755 "$repo_root/target/release/wo-portal" "$stage/usr/bin/wo-portal"
|
||||
install -m755 "$repo_root/target/release/xdg-desktop-portal-wo" "$stage/usr/bin/xdg-desktop-portal-wo"
|
||||
|
||||
install -m755 "$repo_root/electron/node_modules/electron/dist/electron" \
|
||||
"$stage/usr/lib/wo/electron-runtime/electron"
|
||||
cp -a "$repo_root/electron/node_modules/electron/dist/." "$stage/usr/lib/wo/electron-runtime/"
|
||||
cp -a "$repo_root/electron/dist" "$stage/usr/lib/wo/electron/"
|
||||
cp -a "$repo_root/electron/native" "$stage/usr/lib/wo/electron/"
|
||||
cp -a "$repo_root/comraw/dist" "$stage/usr/lib/wo/comraw/"
|
||||
|
||||
cat > "$stage/usr/bin/wo" <<'EOF'
|
||||
#!/bin/sh
|
||||
export WO_DATA_DIR=/usr/lib/wo
|
||||
export WO_ELECTRON_PATH=/usr/lib/wo/electron-runtime/electron
|
||||
export WO_ELECTRON_APP_DIR=/usr/lib/wo/electron
|
||||
exec /usr/lib/wo/bin/wo "$@"
|
||||
EOF
|
||||
chmod 755 "$stage/usr/bin/wo"
|
||||
|
||||
install -m644 "$repo_root/packaging/wayland-sessions/wo.desktop" \
|
||||
"$stage/usr/share/wayland-sessions/wo.desktop"
|
||||
install -m644 "$repo_root/packaging/portals/wo.portal" \
|
||||
"$stage/usr/share/xdg-desktop-portal/portals/wo.portal"
|
||||
install -m644 "$repo_root/packaging/dbus-services/org.freedesktop.impl.portal.desktop.wo.service" \
|
||||
"$stage/usr/share/dbus-1/services/org.freedesktop.impl.portal.desktop.wo.service"
|
||||
install -m644 "$repo_root/config.example.toml" "$stage/usr/share/doc/wo/config.example.toml"
|
||||
|
||||
sed -e "s/@VERSION@/$package_version/" -e "s/@ARCH@/$package_arch/" \
|
||||
"$repo_root/packaging/ubuntu/control.in" > "$stage/DEBIAN/control"
|
||||
dpkg-deb --build --root-owner-group "$stage" "$deb"
|
||||
echo "Built $deb"
|
||||
16
packaging/ubuntu/control.in
Normal file
16
packaging/ubuntu/control.in
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
Package: wo
|
||||
Version: @VERSION@
|
||||
Section: x11
|
||||
Priority: optional
|
||||
Architecture: @ARCH@
|
||||
Maintainer: Stephen E. Hellings <stephen@hellings.cc>
|
||||
Description: Electron based Wayland compositor
|
||||
Wo is a Wayland compositor that hosts Electron surfaces and provides a
|
||||
desktop portal implementation for screen sharing.
|
||||
Depends: libc6, libstdc++6, libgcc-s1, libdrm2, libinput10, libseat1,
|
||||
libxkbcommon0, libgbm1, libegl1, libwayland-client0, libwayland-server0,
|
||||
libpipewire-0.3-0 | libpipewire-0.3-0t64, libnss3,
|
||||
libgtk-3-0 | libgtk-3-0t64, libatk1.0-0 | libatk1.0-0t64,
|
||||
libatk-bridge2.0-0 | libatk-bridge-2.0-0t64,
|
||||
libx11-xcb1, libxcb-dri3-0, libxss1, libasound2 | libasound2t64,
|
||||
xdg-desktop-portal, dbus, xwayland, python3
|
||||
|
|
@ -3,9 +3,9 @@
|
|||
//! This module provides a trait-based abstraction for rendering that works
|
||||
//! with both Nested (GLES via winit) and DRM/KMS backends.
|
||||
|
||||
use crate::dmabuf::ElectronTexture;
|
||||
use anyhow::Result;
|
||||
use smithay::utils::{Physical, Rectangle};
|
||||
use crate::dmabuf::ElectronTexture;
|
||||
|
||||
/// Common interface for rendering frames across different backends
|
||||
#[allow(dead_code)]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,3 @@
|
|||
/// xdg-desktop-portal-wo - Standalone portal service for Wo compositor
|
||||
///
|
||||
/// This is a standalone D-Bus service that implements the xdg-desktop-portal
|
||||
/// interfaces for screen capture. Applications can use this to request screen
|
||||
/// or window capture without direct compositor integration.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
|
@ -11,17 +5,19 @@ use std::sync::Arc;
|
|||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tracing::{info, warn};
|
||||
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
use zbus::{Connection, interface, fdo};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
use zbus::{fdo, interface, Connection};
|
||||
|
||||
use wo::state::portal::{WoPortal, CaptureRequest, SourceType};
|
||||
use wo::state::portal::{CaptureRequest, SourceType, WoPortal};
|
||||
|
||||
fn request_screen_share_consent(
|
||||
app_id: &str,
|
||||
session_id: &str,
|
||||
requested_source: SourceType,
|
||||
) -> Result<Option<(SourceType, Option<String>)>> {
|
||||
let mut stream = UnixStream::connect("/tmp/wo-portal-ui.sock")?;
|
||||
let runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
|
||||
let mut stream =
|
||||
UnixStream::connect(std::path::Path::new(&runtime_dir).join("wo-portal-ui.sock"))?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(95)))?;
|
||||
|
||||
let requested = match requested_source {
|
||||
|
|
@ -49,12 +45,19 @@ fn request_screen_share_consent(
|
|||
}
|
||||
|
||||
let json: serde_json::Value = serde_json::from_str(response.trim())?;
|
||||
let allowed = json.get("allowed").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let allowed = json
|
||||
.get("allowed")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if !allowed {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let source = match json.get("sourceType").and_then(|v| v.as_str()).unwrap_or("Monitor") {
|
||||
let source = match json
|
||||
.get("sourceType")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Monitor")
|
||||
{
|
||||
"Window" => SourceType::Window,
|
||||
"Virtual" => SourceType::Virtual,
|
||||
_ => SourceType::Monitor,
|
||||
|
|
@ -79,12 +82,15 @@ impl ScreenCastImpl {
|
|||
async fn create_session(
|
||||
&self,
|
||||
#[zbus(header)] _hdr: zbus::message::Header<'_>,
|
||||
_handle: zbus::zvariant::ObjectPath<'_>,
|
||||
session_handle: zbus::zvariant::ObjectPath<'_>,
|
||||
_handle: zbus::ObjectPath<'_>,
|
||||
session_handle: zbus::ObjectPath<'_>,
|
||||
app_id: &str,
|
||||
_options: std::collections::HashMap<String, zbus::zvariant::Value<'_>>,
|
||||
) -> fdo::Result<(u32, std::collections::HashMap<String, zbus::zvariant::OwnedValue>)> {
|
||||
info!("CreateSession called: app_id={}, session={:?}", app_id, session_handle);
|
||||
_options: std::collections::HashMap<String, zbus::Variant<'_>>,
|
||||
) -> fdo::Result<(u32, std::collections::HashMap<String, zbus::OwnedVariant>)> {
|
||||
info!(
|
||||
"CreateSession called: app_id={}, session={:?}",
|
||||
app_id, session_handle
|
||||
);
|
||||
|
||||
let internal_session_id = self.portal.create_session();
|
||||
let external_session_id = session_handle
|
||||
|
|
@ -98,7 +104,7 @@ impl ScreenCastImpl {
|
|||
.unwrap()
|
||||
.insert(external_session_id, internal_session_id);
|
||||
|
||||
let response: std::collections::HashMap<String, zbus::zvariant::OwnedValue> =
|
||||
let response: std::collections::HashMap<String, zbus::OwnedVariant> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
Ok((0, response)) // 0 = success
|
||||
|
|
@ -108,11 +114,11 @@ impl ScreenCastImpl {
|
|||
async fn select_sources(
|
||||
&self,
|
||||
#[zbus(header)] _hdr: zbus::message::Header<'_>,
|
||||
_handle: zbus::zvariant::ObjectPath<'_>,
|
||||
session_handle: zbus::zvariant::ObjectPath<'_>,
|
||||
_handle: zbus::ObjectPath<'_>,
|
||||
session_handle: zbus::ObjectPath<'_>,
|
||||
app_id: &str,
|
||||
options: std::collections::HashMap<String, zbus::zvariant::Value<'_>>,
|
||||
) -> fdo::Result<(u32, std::collections::HashMap<String, zbus::zvariant::OwnedValue>)> {
|
||||
options: std::collections::HashMap<String, zbus::Variant<'_>>,
|
||||
) -> fdo::Result<(u32, std::collections::HashMap<String, zbus::OwnedVariant>)> {
|
||||
info!("SelectSources called for session: {:?}", session_handle);
|
||||
|
||||
// Extract source type from options
|
||||
|
|
@ -154,8 +160,11 @@ impl ScreenCastImpl {
|
|||
};
|
||||
|
||||
let Some((selected_source, selected_target)) = consent else {
|
||||
info!("Screen share request denied/cancelled for session: {}", session_id);
|
||||
let response: std::collections::HashMap<String, zbus::zvariant::OwnedValue> =
|
||||
info!(
|
||||
"Screen share request denied/cancelled for session: {}",
|
||||
session_id
|
||||
);
|
||||
let response: std::collections::HashMap<String, zbus::OwnedVariant> =
|
||||
std::collections::HashMap::new();
|
||||
return Ok((1, response));
|
||||
};
|
||||
|
|
@ -170,10 +179,13 @@ impl ScreenCastImpl {
|
|||
};
|
||||
|
||||
if let Err(e) = self.portal.select_sources(&session_id, request) {
|
||||
return Err(fdo::Error::Failed(format!("Failed to select sources: {}", e)));
|
||||
return Err(fdo::Error::Failed(format!(
|
||||
"Failed to select sources: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
|
||||
let response: std::collections::HashMap<String, zbus::zvariant::OwnedValue> =
|
||||
let response: std::collections::HashMap<String, zbus::OwnedVariant> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
Ok((0, response))
|
||||
|
|
@ -183,12 +195,12 @@ impl ScreenCastImpl {
|
|||
async fn start(
|
||||
&self,
|
||||
#[zbus(header)] _hdr: zbus::message::Header<'_>,
|
||||
_handle: zbus::zvariant::ObjectPath<'_>,
|
||||
session_handle: zbus::zvariant::ObjectPath<'_>,
|
||||
_handle: zbus::ObjectPath<'_>,
|
||||
session_handle: zbus::ObjectPath<'_>,
|
||||
_app_id: &str,
|
||||
_parent_window: &str,
|
||||
_options: std::collections::HashMap<String, zbus::zvariant::Value<'_>>,
|
||||
) -> fdo::Result<(u32, std::collections::HashMap<String, zbus::zvariant::OwnedValue>)> {
|
||||
_options: std::collections::HashMap<String, zbus::Variant<'_>>,
|
||||
) -> fdo::Result<(u32, std::collections::HashMap<String, zbus::OwnedVariant>)> {
|
||||
info!("Start called for session: {:?}", session_handle);
|
||||
|
||||
let external_session_id = session_handle
|
||||
|
|
@ -210,40 +222,59 @@ impl ScreenCastImpl {
|
|||
|
||||
match self.portal.start_session(&session_id, width, height) {
|
||||
Ok(capture_response) => {
|
||||
use zbus::zvariant::OwnedValue;
|
||||
use zbus::{OwnedVariant, Variant};
|
||||
|
||||
let mut response: std::collections::HashMap<String, OwnedValue> =
|
||||
let mut response: std::collections::HashMap<String, OwnedVariant> =
|
||||
std::collections::HashMap::new();
|
||||
let mut streams = Vec::with_capacity(capture_response.streams.len());
|
||||
|
||||
|
||||
// Log stream activation and frame queueing begins
|
||||
for stream in &capture_response.streams {
|
||||
let src_type = match stream.source_type {
|
||||
wo::state::portal::SourceType::Monitor => "Monitor",
|
||||
wo::state::portal::SourceType::Window => "Window",
|
||||
wo::state::portal::SourceType::Virtual => "Virtual",
|
||||
};
|
||||
info!("Activated stream {}: {} [{}x{}] with frame queueing",
|
||||
stream.node_id, src_type, stream.width, stream.height);
|
||||
}
|
||||
|
||||
// Return success status - actual streaming happens via frame queueing
|
||||
let stream_count = capture_response.streams.len() as i32;
|
||||
response.insert("status".to_string(), OwnedValue::from(0i32));
|
||||
response.insert("stream_count".to_string(), OwnedValue::from(stream_count));
|
||||
if let Some(first) = capture_response.streams.first() {
|
||||
response.insert("primary_node_id".to_string(), OwnedValue::from(first.node_id as i32));
|
||||
}
|
||||
|
||||
for stream in &capture_response.streams {
|
||||
info!(" Frame queue for stream {}: 0/{} frames queued", stream.node_id, 30);
|
||||
info!(
|
||||
"Activated stream {}: {} [{}x{}] with frame queueing",
|
||||
stream.node_id, src_type, stream.width, stream.height
|
||||
);
|
||||
|
||||
let source_type = match stream.source_type {
|
||||
wo::state::portal::SourceType::Monitor => 1u32,
|
||||
wo::state::portal::SourceType::Window => 2u32,
|
||||
wo::state::portal::SourceType::Virtual => 4u32,
|
||||
};
|
||||
let mut properties = std::collections::HashMap::new();
|
||||
properties.insert(
|
||||
"size".to_string(),
|
||||
OwnedVariant::try_from(Variant::new((
|
||||
stream.width as i32,
|
||||
stream.height as i32,
|
||||
)))
|
||||
.map_err(|error| fdo::Error::Failed(error.to_string()))?,
|
||||
);
|
||||
properties.insert("source_type".to_string(), OwnedVariant::from(source_type));
|
||||
if stream.source_type == wo::state::portal::SourceType::Monitor {
|
||||
properties.insert(
|
||||
"position".to_string(),
|
||||
OwnedVariant::try_from(Variant::new((0i32, 0i32)))
|
||||
.map_err(|error| fdo::Error::Failed(error.to_string()))?,
|
||||
);
|
||||
}
|
||||
streams.push((stream.node_id, properties));
|
||||
}
|
||||
|
||||
response.insert(
|
||||
"streams".to_string(),
|
||||
OwnedVariant::try_from(Variant::new(streams))
|
||||
.map_err(|error| fdo::Error::Failed(error.to_string()))?,
|
||||
);
|
||||
Ok((0, response))
|
||||
}
|
||||
Err(e) => {
|
||||
Err(fdo::Error::Failed(format!("Failed to start session: {}", e)))
|
||||
}
|
||||
Err(e) => Err(fdo::Error::Failed(format!(
|
||||
"Failed to start session: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -257,16 +288,9 @@ async fn main() -> Result<()> {
|
|||
|
||||
info!("xdg-desktop-portal-wo starting");
|
||||
|
||||
// Load config to get compositor IPC socket path
|
||||
let config = wo::config::Config::load().unwrap_or_else(|_| {
|
||||
info!("Using default config");
|
||||
wo::config::Config::default()
|
||||
});
|
||||
|
||||
// Create the portal backend with compositor socket
|
||||
let portal = Arc::new(
|
||||
WoPortal::new().with_compositor_socket(config.compositor.ipc_socket.clone())
|
||||
);
|
||||
// Wo streams the compositor's rendered frames directly into PipeWire.
|
||||
// The binary compositor IPC socket is not a JSON control channel.
|
||||
let portal = Arc::new(WoPortal::new());
|
||||
|
||||
// Connect to the session bus
|
||||
let connection = Connection::session().await?;
|
||||
|
|
@ -289,7 +313,6 @@ async fn main() -> Result<()> {
|
|||
.await?;
|
||||
|
||||
info!("Portal service registered as org.freedesktop.impl.portal.desktop.wo");
|
||||
info!("Compositor IPC socket: {}", config.compositor.ipc_socket);
|
||||
info!("Listening for screen capture requests...");
|
||||
|
||||
// Keep the service running
|
||||
|
|
|
|||
161
src/config.rs
161
src/config.rs
|
|
@ -1,7 +1,7 @@
|
|||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// Compositor configuration
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
|
@ -83,7 +83,7 @@ pub struct CompositorConfig {
|
|||
#[serde(default)]
|
||||
pub applications: Vec<ApplicationConfig>,
|
||||
|
||||
/// XCursor theme name (default: "breeze_cursors")
|
||||
/// XCursor theme name (defaults to XCURSOR_THEME, then Adwaita)
|
||||
#[serde(default = "default_cursor_theme")]
|
||||
pub cursor_theme: String,
|
||||
|
||||
|
|
@ -92,10 +92,9 @@ pub struct CompositorConfig {
|
|||
pub cursor_size: u32,
|
||||
|
||||
/// Extra environment variables to set before spawning child processes.
|
||||
/// Defaults include DISPLAY=":0" so X11 applications (e.g. Steam) can
|
||||
/// fall back to a sensible value when XWayland hasn't started yet.
|
||||
/// XWayland will override DISPLAY with the actual value once ready.
|
||||
#[serde(default = "default_environment")]
|
||||
/// DISPLAY is deliberately not guessed: Wo publishes the actual XWayland
|
||||
/// display after its server is ready.
|
||||
#[serde(default)]
|
||||
pub environment: HashMap<String, String>,
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +117,7 @@ impl Default for CompositorConfig {
|
|||
applications: vec![],
|
||||
cursor_theme: default_cursor_theme(),
|
||||
cursor_size: default_cursor_size(),
|
||||
environment: default_environment(),
|
||||
environment: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -126,6 +125,10 @@ impl Default for CompositorConfig {
|
|||
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
||||
pub struct WindowConfig {
|
||||
pub name: String,
|
||||
/// Set internally for compositor-owned root surfaces. Never read from
|
||||
/// user configuration or serialized to the Electron window payload.
|
||||
#[serde(skip)]
|
||||
pub system_shell: bool,
|
||||
pub url: Option<String>,
|
||||
pub html: Option<String>,
|
||||
pub css: Option<String>,
|
||||
|
|
@ -264,9 +267,15 @@ impl ApplicationConfig {
|
|||
|
||||
// Defaults
|
||||
|
||||
fn default_socket_name() -> String { "wo-0".into() }
|
||||
fn default_electron_path() -> String { "electron".into() }
|
||||
fn default_background() -> [f32; 4] { [0.1, 0.1, 0.1, 1.0] }
|
||||
fn default_socket_name() -> String {
|
||||
"wo-0".into()
|
||||
}
|
||||
fn default_electron_path() -> String {
|
||||
std::env::var("WO_ELECTRON_PATH").unwrap_or_else(|_| "electron".into())
|
||||
}
|
||||
fn default_background() -> [f32; 4] {
|
||||
[0.1, 0.1, 0.1, 1.0]
|
||||
}
|
||||
|
||||
/// Returns $XDG_RUNTIME_DIR, falling back to /run/user/<uid>.
|
||||
fn runtime_dir() -> String {
|
||||
|
|
@ -274,23 +283,41 @@ fn runtime_dir() -> String {
|
|||
.unwrap_or_else(|_| format!("/run/user/{}", unsafe { libc::getuid() }))
|
||||
}
|
||||
|
||||
fn default_ipc_socket() -> String { format!("{}/wo-ipc.sock", runtime_dir()) }
|
||||
fn default_portal_socket() -> String { format!("{}/wo-portal.sock", runtime_dir()) }
|
||||
fn default_width() -> u32 { 1920 }
|
||||
fn default_height() -> u32 { 1080 }
|
||||
fn default_nested_width() -> u32 { 1920 }
|
||||
fn default_nested_height() -> u32 { 1080 }
|
||||
fn default_fps() -> u32 { 60 }
|
||||
fn default_format() -> String { "ARGB8888".into() }
|
||||
fn default_asset_prefix() -> String { "file".into() }
|
||||
fn default_true() -> bool { true }
|
||||
fn default_cursor_theme() -> String { "breeze_cursors".into() }
|
||||
fn default_cursor_size() -> u32 { 24 }
|
||||
|
||||
fn default_environment() -> HashMap<String, String> {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("DISPLAY".into(), ":0".into());
|
||||
env
|
||||
fn default_ipc_socket() -> String {
|
||||
format!("{}/wo-ipc.sock", runtime_dir())
|
||||
}
|
||||
fn default_portal_socket() -> String {
|
||||
format!("{}/wo-portal.sock", runtime_dir())
|
||||
}
|
||||
fn default_width() -> u32 {
|
||||
1920
|
||||
}
|
||||
fn default_height() -> u32 {
|
||||
1080
|
||||
}
|
||||
fn default_nested_width() -> u32 {
|
||||
1920
|
||||
}
|
||||
fn default_nested_height() -> u32 {
|
||||
1080
|
||||
}
|
||||
fn default_fps() -> u32 {
|
||||
0
|
||||
}
|
||||
fn default_format() -> String {
|
||||
"ARGB8888".into()
|
||||
}
|
||||
fn default_asset_prefix() -> String {
|
||||
"file".into()
|
||||
}
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_cursor_theme() -> String {
|
||||
std::env::var("XCURSOR_THEME").unwrap_or_else(|_| "Adwaita".into())
|
||||
}
|
||||
fn default_cursor_size() -> u32 {
|
||||
24
|
||||
}
|
||||
|
||||
fn expand_path(s: &str) -> String {
|
||||
|
|
@ -305,11 +332,13 @@ impl Config {
|
|||
// 1. WO_CONFIG environment variable
|
||||
// 2. ./config.toml (current directory)
|
||||
// 3. ~/.config/wo/config.toml (user config)
|
||||
// 4. Installed package defaults (Ubuntu/Debian)
|
||||
|
||||
let paths = vec![
|
||||
std::env::var("WO_CONFIG").ok().map(PathBuf::from),
|
||||
Some(PathBuf::from("./config.toml")),
|
||||
Some(config_path()),
|
||||
Some(PathBuf::from("/usr/share/doc/wo/config.example.toml")),
|
||||
];
|
||||
|
||||
for path_opt in paths.into_iter().flatten() {
|
||||
|
|
@ -320,10 +349,8 @@ impl Config {
|
|||
|
||||
let mut config: Self = toml::from_str(&text)
|
||||
.with_context(|| format!("parsing config {path_opt:?}"))?;
|
||||
config.compositor.ipc_socket =
|
||||
expand_path(&config.compositor.ipc_socket);
|
||||
config.compositor.portal_socket =
|
||||
expand_path(&config.compositor.portal_socket);
|
||||
config.compositor.ipc_socket = expand_path(&config.compositor.ipc_socket);
|
||||
config.compositor.portal_socket = expand_path(&config.compositor.portal_socket);
|
||||
return Ok(config);
|
||||
}
|
||||
}
|
||||
|
|
@ -338,15 +365,14 @@ impl Config {
|
|||
|
||||
/// Apply configured environment variables to the current process so all
|
||||
/// subsequently-spawned children inherit them. Existing env vars are
|
||||
/// **not** overwritten so that, e.g., a DISPLAY already set by the
|
||||
/// parent session or XWayland takes priority.
|
||||
/// preserved, including the active parent-session DISPLAY.
|
||||
pub fn apply_environment(&self) {
|
||||
for (key, value) in &self.compositor.environment {
|
||||
if std::env::var(key).is_err() {
|
||||
tracing::info!("Setting environment {key}={value} (from config default)");
|
||||
tracing::info!("Setting environment {key}={value} (from config)");
|
||||
std::env::set_var(key, value);
|
||||
} else {
|
||||
tracing::debug!("Environment {key} already set, skipping config default");
|
||||
tracing::debug!("Environment {key} already set, preserving it");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -357,6 +383,9 @@ fn config_path() -> PathBuf {
|
|||
}
|
||||
|
||||
fn dirs_config() -> PathBuf {
|
||||
if let Some(config_home) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||
return PathBuf::from(config_home).join("wo");
|
||||
}
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
|
||||
PathBuf::from(home).join(".config/wo")
|
||||
}
|
||||
|
|
@ -372,3 +401,65 @@ impl Default for Config {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Config;
|
||||
|
||||
#[test]
|
||||
fn empty_config_uses_headless_safe_defaults() {
|
||||
let config: Config = toml::from_str("").expect("empty config should deserialize");
|
||||
|
||||
assert!(config.windows.is_empty());
|
||||
assert!(config.root.is_empty());
|
||||
assert!(!config.compositor.enable_portal);
|
||||
assert_eq!(config.compositor.nested_width, 1920);
|
||||
assert_eq!(config.compositor.nested_height, 1080);
|
||||
assert_eq!(config.compositor.socket_name, "wo-0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_defaults_and_explicit_portal_options_deserialize() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[compositor]
|
||||
enable_portal = true
|
||||
nested = true
|
||||
nested_width = 1280
|
||||
nested_height = 720
|
||||
|
||||
[[windows]]
|
||||
name = "overlay"
|
||||
html = "<main>ready</main>"
|
||||
width = 640
|
||||
height = 360
|
||||
"#,
|
||||
)
|
||||
.expect("valid headless config should deserialize");
|
||||
|
||||
assert!(config.compositor.enable_portal);
|
||||
assert_eq!(config.compositor.nested, Some(true));
|
||||
assert_eq!(config.compositor.nested_width, 1280);
|
||||
assert_eq!(config.compositor.nested_height, 720);
|
||||
assert_eq!(config.windows.len(), 1);
|
||||
let window = &config.windows[0];
|
||||
assert_eq!(window.name, "overlay");
|
||||
assert_eq!(window.width, 640);
|
||||
assert_eq!(window.height, 360);
|
||||
assert!(window.interactive);
|
||||
assert!(!window.focusable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_window_dimensions_are_rejected() {
|
||||
let error = toml::from_str::<Config>(
|
||||
r#"
|
||||
[[windows]]
|
||||
name = "invalid"
|
||||
width = "wide"
|
||||
"#,
|
||||
);
|
||||
|
||||
assert!(error.is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
140
src/cursor.rs
140
src/cursor.rs
|
|
@ -4,12 +4,12 @@
|
|||
//! GLES textures on first use, caching the result for subsequent frames.
|
||||
|
||||
use smithay::backend::allocator::Fourcc;
|
||||
use smithay::backend::renderer::ImportMem;
|
||||
use smithay::backend::renderer::gles::GlesRenderer;
|
||||
use smithay::backend::renderer::gles::GlesTexture;
|
||||
use smithay::backend::renderer::ImportMem;
|
||||
use smithay::utils::Size;
|
||||
use std::collections::HashMap;
|
||||
use tracing::{info, warn};
|
||||
use tracing::info;
|
||||
|
||||
/// Cached cursor texture with its hotspot offset.
|
||||
#[derive(Clone)]
|
||||
|
|
@ -23,7 +23,7 @@ pub struct CursorImage {
|
|||
|
||||
/// Manages cursor theme loading and GL texture caching.
|
||||
pub struct CursorThemeManager {
|
||||
theme: xcursor::CursorTheme,
|
||||
themes: Vec<xcursor::CursorTheme>,
|
||||
size: u32,
|
||||
cache: HashMap<String, Option<CursorImage>>,
|
||||
}
|
||||
|
|
@ -31,13 +31,24 @@ pub struct CursorThemeManager {
|
|||
impl CursorThemeManager {
|
||||
/// Load an XCursor theme by name with the given nominal cursor size.
|
||||
pub fn new(theme_name: &str, size: u32) -> Self {
|
||||
info!(
|
||||
"Loading cursor theme '{}' (size {})",
|
||||
theme_name, size
|
||||
);
|
||||
let theme = xcursor::CursorTheme::load(theme_name);
|
||||
let mut names = vec![theme_name.to_owned()];
|
||||
for name in ["default", "Adwaita", "DMZ-White", "breeze"] {
|
||||
if !names
|
||||
.iter()
|
||||
.any(|candidate| candidate.eq_ignore_ascii_case(name))
|
||||
{
|
||||
names.push(name.to_owned());
|
||||
}
|
||||
}
|
||||
let themes = names
|
||||
.iter()
|
||||
.map(|name| {
|
||||
info!("Loading cursor theme '{}' (size {})", name, size);
|
||||
xcursor::CursorTheme::load(name)
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
theme,
|
||||
themes,
|
||||
size,
|
||||
cache: HashMap::new(),
|
||||
}
|
||||
|
|
@ -47,11 +58,7 @@ impl CursorThemeManager {
|
|||
///
|
||||
/// Returns `None` if the icon cannot be found in the theme or the texture
|
||||
/// import fails.
|
||||
pub fn get_cursor(
|
||||
&mut self,
|
||||
name: &str,
|
||||
renderer: &mut GlesRenderer,
|
||||
) -> Option<&CursorImage> {
|
||||
pub fn get_cursor(&mut self, name: &str, renderer: &mut GlesRenderer) -> Option<&CursorImage> {
|
||||
if !self.cache.contains_key(name) {
|
||||
let img = self.load_cursor(name, renderer);
|
||||
self.cache.insert(name.to_string(), img);
|
||||
|
|
@ -59,12 +66,12 @@ impl CursorThemeManager {
|
|||
self.cache.get(name).and_then(|opt| opt.as_ref())
|
||||
}
|
||||
|
||||
fn load_cursor(
|
||||
&self,
|
||||
name: &str,
|
||||
renderer: &mut GlesRenderer,
|
||||
) -> Option<CursorImage> {
|
||||
let icon_path = self.theme.load_icon(name).or_else(|| {
|
||||
fn load_cursor(&self, name: &str, renderer: &mut GlesRenderer) -> Option<CursorImage> {
|
||||
let icon_path = self
|
||||
.themes
|
||||
.iter()
|
||||
.find_map(|theme| theme.load_icon(name))
|
||||
.or_else(|| {
|
||||
// Try common XCursor aliases
|
||||
let fallback = match name {
|
||||
"default" => Some("left_ptr"),
|
||||
|
|
@ -103,16 +110,34 @@ impl CursorThemeManager {
|
|||
"zoom-out" => Some("zoom_out"),
|
||||
_ => None,
|
||||
};
|
||||
fallback.and_then(|fb| self.theme.load_icon(fb))
|
||||
})?;
|
||||
fallback.and_then(|fb| self.themes.iter().find_map(|theme| theme.load_icon(fb)))
|
||||
});
|
||||
|
||||
let data = std::fs::read(&icon_path).ok()?;
|
||||
let images = xcursor::parser::parse_xcursor(&data)?;
|
||||
// A root DRM session has no host window-system cursor to fall back to.
|
||||
// Keep a small built-in pointer available when the configured theme is
|
||||
// missing (common in minimal installs) or lacks an individual shape.
|
||||
let Some(icon_path) = icon_path else {
|
||||
info!(
|
||||
"Cursor '{}' is missing from installed themes; using built-in pointer",
|
||||
name
|
||||
);
|
||||
return self.load_builtin_pointer(renderer);
|
||||
};
|
||||
|
||||
let Some(data) = std::fs::read(&icon_path).ok() else {
|
||||
return self.load_builtin_pointer(renderer);
|
||||
};
|
||||
let Some(images) = xcursor::parser::parse_xcursor(&data) else {
|
||||
return self.load_builtin_pointer(renderer);
|
||||
};
|
||||
|
||||
// Pick the image closest to our target size.
|
||||
let best = images
|
||||
.iter()
|
||||
.min_by_key(|img| (img.size as i32 - self.size as i32).unsigned_abs())?;
|
||||
.min_by_key(|img| (img.size as i32 - self.size as i32).unsigned_abs());
|
||||
let Some(best) = best else {
|
||||
return self.load_builtin_pointer(renderer);
|
||||
};
|
||||
|
||||
// XCursor pixels are ARGB (native byte order). Convert to RGBA for
|
||||
// GL import with Fourcc::Abgr8888 (which is RGBA in memory on little-endian).
|
||||
|
|
@ -147,9 +172,70 @@ impl CursorThemeManager {
|
|||
})
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to import cursor texture for '{}': {e}", name);
|
||||
None
|
||||
info!(
|
||||
"Could not import themed cursor '{}': {e}; using built-in pointer",
|
||||
name
|
||||
);
|
||||
self.load_builtin_pointer(renderer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_builtin_pointer(&self, renderer: &mut GlesRenderer) -> Option<CursorImage> {
|
||||
const WIDTH: usize = 16;
|
||||
const HEIGHT: usize = 24;
|
||||
const POLYGON: [(f32, f32); 7] = [
|
||||
(0.5, 0.5),
|
||||
(0.5, 18.0),
|
||||
(4.4, 14.0),
|
||||
(8.2, 23.0),
|
||||
(11.6, 21.3),
|
||||
(7.8, 12.0),
|
||||
(14.0, 12.0),
|
||||
];
|
||||
let inside = |x: f32, y: f32| {
|
||||
let mut hit = false;
|
||||
let mut j = POLYGON.len() - 1;
|
||||
for i in 0..POLYGON.len() {
|
||||
let (xi, yi) = POLYGON[i];
|
||||
let (xj, yj) = POLYGON[j];
|
||||
if (yi > y) != (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi {
|
||||
hit = !hit;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
hit
|
||||
};
|
||||
|
||||
let mut rgba = vec![0u8; WIDTH * HEIGHT * 4];
|
||||
for y in 0..HEIGHT {
|
||||
for x in 0..WIDTH {
|
||||
if !inside(x as f32 + 0.5, y as f32 + 0.5) {
|
||||
continue;
|
||||
}
|
||||
let edge = [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)]
|
||||
.iter()
|
||||
.any(|(dx, dy)| !inside(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy));
|
||||
let value = if edge { 0 } else { 255 };
|
||||
let offset = (y * WIDTH + x) * 4;
|
||||
rgba[offset..offset + 4].copy_from_slice(&[value, value, value, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
renderer
|
||||
.import_memory(
|
||||
&rgba,
|
||||
Fourcc::Abgr8888,
|
||||
Size::from((WIDTH as i32, HEIGHT as i32)),
|
||||
false,
|
||||
)
|
||||
.ok()
|
||||
.map(|texture| CursorImage {
|
||||
texture,
|
||||
width: WIDTH as u32,
|
||||
height: HEIGHT as u32,
|
||||
xhot: 0,
|
||||
yhot: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,18 @@
|
|||
/// On the export side (serving Wayland clients) the compositor creates a
|
||||
/// `zwp_linux_dmabuf_v1` buffer from the same backing GBM BO so that other
|
||||
/// Wayland clients can access the pixel data zero-copy.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use smithay::backend::{
|
||||
allocator::{dmabuf::{Dmabuf, DmabufFlags}, Fourcc, Modifier},
|
||||
renderer::{gles::{GlesRenderer, GlesTexture}, ImportDma},
|
||||
allocator::{
|
||||
dmabuf::{Dmabuf, DmabufFlags},
|
||||
Fourcc, Modifier,
|
||||
},
|
||||
renderer::{
|
||||
gles::{GlesRenderer, GlesTexture},
|
||||
ImportDma,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::electron::ElectronFrame;
|
||||
|
|
@ -55,8 +60,7 @@ impl WindowDmabufCache {
|
|||
.context("seeking memfd to start")?;
|
||||
|
||||
// Write new pixel data
|
||||
unistd::write(&self.memfd_fd, pixels)
|
||||
.context("writing pixels to memfd")?;
|
||||
unistd::write(&self.memfd_fd, pixels).context("writing pixels to memfd")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -86,18 +90,16 @@ pub fn create_window_dmabuf_cache(
|
|||
// Create a memfd for the pixel data
|
||||
let memfd_fd = nix::sys::memfd::memfd_create(
|
||||
std::ffi::CStr::from_bytes_with_nul(b"wayland_window\0").unwrap(),
|
||||
nix::sys::memfd::MFdFlags::MFD_CLOEXEC
|
||||
nix::sys::memfd::MFdFlags::MFD_CLOEXEC,
|
||||
)
|
||||
.context("creating memfd for window DMABUF")?;
|
||||
|
||||
// Truncate to size
|
||||
let size = pixels.len() as u64;
|
||||
nix::unistd::ftruncate(&memfd_fd, size as i64)
|
||||
.context("truncating memfd")?;
|
||||
nix::unistd::ftruncate(&memfd_fd, size as i64).context("truncating memfd")?;
|
||||
|
||||
// Write initial pixel data
|
||||
nix::unistd::write(&memfd_fd, pixels)
|
||||
.context("writing initial pixels to memfd")?;
|
||||
nix::unistd::write(&memfd_fd, pixels).context("writing initial pixels to memfd")?;
|
||||
|
||||
// Seek back to start for DMABUF builder
|
||||
nix::unistd::lseek(&memfd_fd, 0, nix::unistd::Whence::SeekSet)
|
||||
|
|
@ -138,8 +140,13 @@ pub fn import_electron_frame(
|
|||
) -> Result<(ElectronTexture, CachedDmabufFrame)> {
|
||||
use tracing::{debug, trace};
|
||||
|
||||
trace!("Importing DMABUF frame: {}x{} format=0x{:08X} planes={}",
|
||||
frame.width, frame.height, frame.format, frame.planes.len());
|
||||
trace!(
|
||||
"Importing DMABUF frame: {}x{} format=0x{:08X} planes={}",
|
||||
frame.width,
|
||||
frame.height,
|
||||
frame.format,
|
||||
frame.planes.len()
|
||||
);
|
||||
|
||||
let fourcc: Fourcc = fourcc_from_u32(frame.format)
|
||||
.with_context(|| format!("unknown fourcc 0x{:08X}", frame.format))?;
|
||||
|
|
@ -169,7 +176,12 @@ pub fn import_electron_frame(
|
|||
debug!("Failed to add plane {} to DMABUF", idx);
|
||||
anyhow::bail!("too many planes in DMABUF frame");
|
||||
}
|
||||
trace!("Plane {}: offset={} stride={}", idx, plane.offset, plane.stride);
|
||||
trace!(
|
||||
"Plane {}: offset={} stride={}",
|
||||
idx,
|
||||
plane.offset,
|
||||
plane.stride
|
||||
);
|
||||
}
|
||||
|
||||
let dmabuf = builder.build().context("building DMABUF")?;
|
||||
|
|
@ -232,20 +244,16 @@ pub fn create_temp_dmabuf_from_pixels(
|
|||
use smithay::backend::allocator::Modifier;
|
||||
|
||||
// Create a memfd for the pixel data
|
||||
let memfd_fd = nix::sys::memfd::memfd_create(
|
||||
"wayland_window",
|
||||
nix::sys::memfd::MFdFlags::MFD_CLOEXEC
|
||||
)
|
||||
let memfd_fd =
|
||||
nix::sys::memfd::memfd_create("wayland_window", nix::sys::memfd::MFdFlags::MFD_CLOEXEC)
|
||||
.context("creating memfd for DMABUF pixels")?;
|
||||
|
||||
// Truncate to size
|
||||
let size = pixels.len() as u64;
|
||||
nix::unistd::ftruncate(&memfd_fd, size as i64)
|
||||
.context("truncating memfd")?;
|
||||
nix::unistd::ftruncate(&memfd_fd, size as i64).context("truncating memfd")?;
|
||||
|
||||
// Write pixel data directly using nix::unistd::write (no File wrapper)
|
||||
nix::unistd::write(&memfd_fd, pixels)
|
||||
.context("writing pixels to memfd")?;
|
||||
nix::unistd::write(&memfd_fd, pixels).context("writing pixels to memfd")?;
|
||||
|
||||
// Seek back to start
|
||||
nix::unistd::lseek(&memfd_fd, 0, nix::unistd::Whence::SeekSet)
|
||||
|
|
@ -265,8 +273,7 @@ pub fn create_temp_dmabuf_from_pixels(
|
|||
builder.build().context("building DMABUF from pixels")
|
||||
}
|
||||
|
||||
/// Export an offscreen texture as a DMABUF for zero-copy transmission.
|
||||
/// Used to export Wayland window textures to comraw via IPC using individual DMABUFs.
|
||||
/// Direct EGL texture export is currently unsupported.
|
||||
#[allow(dead_code)]
|
||||
pub fn export_texture_as_dmabuf(
|
||||
_renderer: &GlesRenderer,
|
||||
|
|
@ -274,11 +281,6 @@ pub fn export_texture_as_dmabuf(
|
|||
_width: u32,
|
||||
_height: u32,
|
||||
) -> Result<Dmabuf> {
|
||||
// For now, we'll read pixels and create a DMABUF from them
|
||||
// A more optimal approach would use EGLImage to back the DMABUF, but
|
||||
// this is simpler and still zero-copy at the IPC boundary
|
||||
// This would need a mutable reference, which we don't have
|
||||
// So instead, for each window we'll create DMABUF on-demand after pixelreads
|
||||
anyhow::bail!("texture export not directly supported; use create_temp_dmabuf_from_pixels after reading pixels")
|
||||
}
|
||||
|
||||
|
|
|
|||
632
src/electron.rs
632
src/electron.rs
File diff suppressed because it is too large
Load diff
|
|
@ -28,11 +28,16 @@ impl CompositorHandler for WoState {
|
|||
if let Some(data) = client.get_data::<crate::state::ClientData>() {
|
||||
return &data.compositor_state;
|
||||
}
|
||||
static LOGGED_ONCE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
static LOGGED_ONCE: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
if !LOGGED_ONCE.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
tracing::info!("Client missing ClientData (likely XWayland), using fallback compositor state");
|
||||
tracing::info!(
|
||||
"Client missing ClientData (likely XWayland), using fallback compositor state"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!("Client missing ClientData (likely XWayland), using fallback compositor state");
|
||||
tracing::debug!(
|
||||
"Client missing ClientData (likely XWayland), using fallback compositor state"
|
||||
);
|
||||
}
|
||||
static FALLBACK: std::sync::OnceLock<CompositorClientState> = std::sync::OnceLock::new();
|
||||
FALLBACK.get_or_init(CompositorClientState::default)
|
||||
|
|
@ -52,7 +57,11 @@ impl CompositorHandler for WoState {
|
|||
if let Some(window) = self
|
||||
.space
|
||||
.elements()
|
||||
.find(|w| w.toplevel().map(|t| t.wl_surface() == &root).unwrap_or(false))
|
||||
.find(|w| {
|
||||
w.toplevel()
|
||||
.map(|t| t.wl_surface() == &root)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned()
|
||||
{
|
||||
window.on_commit();
|
||||
|
|
@ -71,11 +80,14 @@ impl CompositorHandler for WoState {
|
|||
window.on_commit();
|
||||
self.dirty_surfaces.insert(root.clone());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
self.popup_manager.commit(surface);
|
||||
crate::handlers::xdg_shell::ensure_initial_configure(surface, &self.space, &mut self.popup_manager);
|
||||
crate::handlers::xdg_shell::ensure_initial_configure(
|
||||
surface,
|
||||
&self.space,
|
||||
&mut self.popup_manager,
|
||||
);
|
||||
if self.debug_new_toplevel {
|
||||
tracing::info!("commit handler EXIT");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
use smithay::{
|
||||
backend::allocator::dmabuf::Dmabuf,
|
||||
delegate_dmabuf,
|
||||
wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, DmabufFeedback, ImportNotifier}
|
||||
wayland::dmabuf::{DmabufFeedback, DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier},
|
||||
};
|
||||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
|
||||
use crate::state::WoState;
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ impl DmabufHandler for WoState {
|
|||
_dmabuf: Dmabuf,
|
||||
notifier: ImportNotifier,
|
||||
) {
|
||||
tracing::info!("dmabuf_imported: accepting DMABUF unconditionally");
|
||||
tracing::debug!("dmabuf_imported: accepting renderer-advertised DMABUF");
|
||||
// Frames from Electron arrive via the custom IPC channel, not via the
|
||||
// Wayland DMABUF protocol. However, native Wayland clients (and Electron's
|
||||
// own GPU subprocess when it connects as a Wayland client) import DMABUFs
|
||||
|
|
@ -29,7 +29,6 @@ impl DmabufHandler for WoState {
|
|||
// failure response to the client, preventing it from creating DMABUF-backed
|
||||
// wl_buffers entirely and causing the app (and rendering) to freeze.
|
||||
let _ = notifier.successful::<WoState>();
|
||||
tracing::info!("dmabuf_imported: done");
|
||||
}
|
||||
|
||||
fn new_surface_feedback(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use smithay::{
|
|||
AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, InputBackend, InputEvent,
|
||||
KeyState, KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent,
|
||||
},
|
||||
desktop::WindowSurfaceType,
|
||||
input::keyboard::FilterResult,
|
||||
utils::SERIAL_COUNTER,
|
||||
};
|
||||
|
|
@ -155,11 +156,8 @@ impl WoState {
|
|||
let delta = (event.delta_x(), event.delta_y());
|
||||
|
||||
let current = self.pointer_location;
|
||||
let new_pos: smithay::utils::Point<f64, smithay::utils::Logical> = (
|
||||
(current.x + delta.0).clamp(0.0, self.output_size.0 as f64),
|
||||
(current.y + delta.1).clamp(0.0, self.output_size.1 as f64),
|
||||
)
|
||||
.into();
|
||||
let new_pos: smithay::utils::Point<f64, smithay::utils::Logical> =
|
||||
self.clamp_pointer_to_monitors((current.x + delta.0, current.y + delta.1).into());
|
||||
|
||||
// Hardware is the sole source of truth for pointer position.
|
||||
self.pointer_location = new_pos;
|
||||
|
|
@ -241,11 +239,7 @@ impl WoState {
|
|||
.unwrap_or(initial_window_location);
|
||||
self.space
|
||||
.map_element(window.clone(), (new_x, new_y), false);
|
||||
self.translate_dialog_children(
|
||||
&window,
|
||||
new_x - prev_loc.x,
|
||||
new_y - prev_loc.y,
|
||||
);
|
||||
self.translate_dialog_children(&window, new_x - prev_loc.x, new_y - prev_loc.y);
|
||||
self.metadata_dirty = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -256,9 +250,16 @@ impl WoState {
|
|||
}
|
||||
|
||||
fn on_pointer_motion_abs<B: InputBackend>(&mut self, event: B::PointerMotionAbsoluteEvent) {
|
||||
let new_pos: smithay::utils::Point<f64, smithay::utils::Logical> = event
|
||||
.position_transformed((self.output_size.0 as i32, self.output_size.1 as i32).into())
|
||||
.into();
|
||||
let bounds = self.desktop_bounds();
|
||||
let transformed = event.position_transformed(bounds.size);
|
||||
let new_pos: smithay::utils::Point<f64, smithay::utils::Logical> = self
|
||||
.clamp_pointer_to_monitors(
|
||||
(
|
||||
transformed.x + bounds.loc.x as f64,
|
||||
transformed.y + bounds.loc.y as f64,
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
|
||||
// Hardware is the sole source of truth for pointer position.
|
||||
self.pointer_location = new_pos;
|
||||
|
|
@ -339,11 +340,7 @@ impl WoState {
|
|||
.unwrap_or(initial_window_location);
|
||||
self.space
|
||||
.map_element(window.clone(), (new_x, new_y), false);
|
||||
self.translate_dialog_children(
|
||||
&window,
|
||||
new_x - prev_loc.x,
|
||||
new_y - prev_loc.y,
|
||||
);
|
||||
self.translate_dialog_children(&window, new_x - prev_loc.x, new_y - prev_loc.y);
|
||||
self.metadata_dirty = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -405,12 +402,16 @@ impl WoState {
|
|||
let local_x = self.pointer_location.x - win_loc.x as f64;
|
||||
let local_y = self.pointer_location.y - win_loc.y as f64;
|
||||
window
|
||||
.surface_under(Point::<f64, Logical>::from((local_x, local_y)), WindowSurfaceType::ALL)
|
||||
.surface_under(
|
||||
Point::<f64, Logical>::from((local_x, local_y)),
|
||||
WindowSurfaceType::ALL,
|
||||
)
|
||||
.map(|(s, offset)| {
|
||||
let surface_global: Point<f64, Logical> = (
|
||||
win_loc.x as f64 + offset.x as f64,
|
||||
win_loc.y as f64 + offset.y as f64,
|
||||
).into();
|
||||
)
|
||||
.into();
|
||||
(s, surface_global)
|
||||
})
|
||||
});
|
||||
|
|
@ -452,7 +453,9 @@ impl WoState {
|
|||
// pointer_button action will arrive shortly and set the
|
||||
// correct Wayland surface focus. Clearing it here would
|
||||
// cause focus to bounce (clear → set → clear → set).
|
||||
let has_native_focus = self.seat.get_keyboard()
|
||||
let has_native_focus = self
|
||||
.seat
|
||||
.get_keyboard()
|
||||
.map(|kb| kb.current_focus().is_some())
|
||||
.unwrap_or(false);
|
||||
if !has_native_focus {
|
||||
|
|
@ -488,8 +491,16 @@ impl WoState {
|
|||
let keyboard = self.seat.get_keyboard().unwrap();
|
||||
keyboard.set_focus(self, Some(surface.clone()), serial);
|
||||
self.keyboard_window_focus = None;
|
||||
}
|
||||
} else {
|
||||
// Clicked empty space: clear focus.
|
||||
self.keyboard_window_focus = None;
|
||||
let keyboard = self.seat.get_keyboard().unwrap();
|
||||
keyboard.set_focus(self, None, serial);
|
||||
}
|
||||
}
|
||||
|
||||
let pointer = self.seat.get_pointer().unwrap();
|
||||
if let Some(pointer) = self.seat.get_pointer() {
|
||||
pointer.button(
|
||||
self,
|
||||
&smithay::input::pointer::ButtonEvent {
|
||||
|
|
@ -499,16 +510,49 @@ impl WoState {
|
|||
time,
|
||||
},
|
||||
);
|
||||
pointer.frame(self);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if button_state == ButtonState::Pressed {
|
||||
use smithay::utils::{Logical, Point};
|
||||
let pos: Point<f64, Logical> = self.pointer_location;
|
||||
let under_surface = self.space.element_under(pos);
|
||||
|
||||
if let Some((window, _)) = under_surface {
|
||||
let surface = window
|
||||
.x11_surface()
|
||||
.and_then(|x11| x11.wl_surface())
|
||||
.or_else(|| {
|
||||
window
|
||||
.toplevel()
|
||||
.map(|toplevel| toplevel.wl_surface().clone())
|
||||
});
|
||||
if let (Some(surface), Some(keyboard)) = (surface, self.seat.get_keyboard()) {
|
||||
keyboard.set_focus(self, Some(surface), serial);
|
||||
self.keyboard_window_focus = None;
|
||||
}
|
||||
} else {
|
||||
// Clicked empty space: clear focus.
|
||||
self.keyboard_window_focus = None;
|
||||
let keyboard = self.seat.get_keyboard().unwrap();
|
||||
if let Some(keyboard) = self.seat.get_keyboard() {
|
||||
keyboard.set_focus(self, None, serial);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
if let Some(pointer) = self.seat.get_pointer() {
|
||||
pointer.button(
|
||||
self,
|
||||
&smithay::input::pointer::ButtonEvent {
|
||||
button: button_code,
|
||||
state: button_state,
|
||||
serial,
|
||||
time,
|
||||
},
|
||||
);
|
||||
pointer.frame(self);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -539,18 +583,55 @@ impl WoState {
|
|||
if vertical != 0 || horizontal != 0 {
|
||||
if let Some(client) = ipc.clients.lock().unwrap().get(win_name).cloned() {
|
||||
// Non-blocking send.
|
||||
let _ = client.try_send_input_event(&ElectronInputEvent::Scroll {
|
||||
if client
|
||||
.try_send_input_event(&ElectronInputEvent::Scroll {
|
||||
vertical,
|
||||
horizontal,
|
||||
time,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.is_ok()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.dispatch_native_pointer_axis::<B>(event, time);
|
||||
return;
|
||||
}
|
||||
|
||||
self.dispatch_native_pointer_axis::<B>(event, time);
|
||||
}
|
||||
|
||||
fn dispatch_native_pointer_axis<B: InputBackend>(
|
||||
&mut self,
|
||||
event: B::PointerAxisEvent,
|
||||
time: u32,
|
||||
) {
|
||||
use smithay::input::pointer::AxisFrame;
|
||||
|
||||
let Some(pointer) = self.seat.get_pointer() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut frame = AxisFrame::new(time).source(event.source());
|
||||
for axis in [Axis::Horizontal, Axis::Vertical] {
|
||||
if let Some(value) = event.amount(axis) {
|
||||
frame = frame.value(axis, value);
|
||||
}
|
||||
if let Some(v120) = event.amount_v120(axis) {
|
||||
frame = frame.v120(axis, v120.round() as i32);
|
||||
if event.amount(axis).is_none() {
|
||||
frame = frame.value(axis, v120 / 120.0 * 15.0);
|
||||
}
|
||||
}
|
||||
frame = frame.relative_direction(axis, event.relative_direction(axis));
|
||||
}
|
||||
|
||||
pointer.axis(self, frame);
|
||||
pointer.frame(self);
|
||||
}
|
||||
|
||||
/// Forward a pointer motion event from the client (comraw/Electron) to the
|
||||
/// Wayland surface under the given window-local coordinates.
|
||||
|
|
@ -579,21 +660,29 @@ impl WoState {
|
|||
let win_loc = self.space.element_location(&window).unwrap_or_default();
|
||||
|
||||
// Compute a synthetic global location from the client-provided
|
||||
// surface-local coords. This is used ONLY for the MotionEvent so
|
||||
// Smithay derives the correct wl_pointer surface-local coordinates.
|
||||
// client-area coords. This is used only for MotionEvent so Smithay
|
||||
// derives the correct wl_pointer surface-local coordinates.
|
||||
// pointer_location itself is NOT modified.
|
||||
let dispatch_location: Point<f64, Logical> = (
|
||||
win_loc.x as f64 + x,
|
||||
win_loc.y as f64 + y,
|
||||
).into();
|
||||
// The shell reports coordinates relative to the visible surface area.
|
||||
// Convert them to window-element coordinates; geometry.loc marks the
|
||||
// content inset inside that element (for example client shadow pixels).
|
||||
let geometry_offset = window.geometry().loc;
|
||||
let surface_x = x + geometry_offset.x as f64;
|
||||
let surface_y = y + geometry_offset.y as f64;
|
||||
let dispatch_location: Point<f64, Logical> =
|
||||
(win_loc.x as f64 + surface_x, win_loc.y as f64 + surface_y).into();
|
||||
|
||||
let focus = window
|
||||
.surface_under(Point::<f64, Logical>::from((x, y)), WindowSurfaceType::ALL)
|
||||
.surface_under(
|
||||
Point::<f64, Logical>::from((surface_x, surface_y)),
|
||||
WindowSurfaceType::ALL,
|
||||
)
|
||||
.map(|(s, offset)| {
|
||||
let focus_pos: Point<f64, Logical> = (
|
||||
win_loc.x as f64 + offset.x as f64,
|
||||
win_loc.y as f64 + offset.y as f64,
|
||||
).into();
|
||||
)
|
||||
.into();
|
||||
(s, focus_pos)
|
||||
});
|
||||
|
||||
|
|
@ -757,19 +846,32 @@ impl WoState {
|
|||
}
|
||||
|
||||
/// Forward a pointer scroll event from the web UI.
|
||||
pub fn handle_forwarded_pointer_scroll(&mut self, window_name: &str, dx: f64, dy: f64) {
|
||||
pub fn handle_forwarded_pointer_scroll(
|
||||
&mut self,
|
||||
window_name: &str,
|
||||
x: f64,
|
||||
y: f64,
|
||||
dx: f64,
|
||||
dy: f64,
|
||||
time: u32,
|
||||
) {
|
||||
use smithay::input::pointer::AxisFrame;
|
||||
|
||||
if self.find_wayland_window(window_name).is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Position the Wayland pointer from the same wheel event. Keeping
|
||||
// motion+axis in one IPC message prevents a wheel from arriving while
|
||||
// Smithay still has focus on the previous surface.
|
||||
self.handle_forwarded_pointer_event(window_name, x, y);
|
||||
|
||||
let pointer = match self.seat.get_pointer() {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let mut frame = AxisFrame::new(0).source(AxisSource::Wheel);
|
||||
let mut frame = AxisFrame::new(time).source(AxisSource::Wheel);
|
||||
if dx != 0.0 {
|
||||
frame = frame.value(Axis::Horizontal, dx);
|
||||
}
|
||||
|
|
@ -780,30 +882,83 @@ impl WoState {
|
|||
pointer.frame(self);
|
||||
}
|
||||
|
||||
/// End forwarded pointer focus after the web shell leaves a native window.
|
||||
pub fn handle_forwarded_pointer_leave(&mut self, window_name: &str) {
|
||||
let Some(window) = self.find_wayland_window(window_name) else {
|
||||
return;
|
||||
};
|
||||
let Some(pointer) = self.seat.get_pointer() else {
|
||||
return;
|
||||
};
|
||||
let surface = window
|
||||
.toplevel()
|
||||
.map(|toplevel| toplevel.wl_surface().clone())
|
||||
.or_else(|| window.x11_surface().and_then(|x11| x11.wl_surface()));
|
||||
let pointer_locked = surface.as_ref().is_some_and(|surface| {
|
||||
smithay::wayland::pointer_constraints::with_pointer_constraint(
|
||||
surface,
|
||||
&pointer,
|
||||
|constraint| {
|
||||
constraint.is_some_and(|constraint| {
|
||||
matches!(
|
||||
&*constraint,
|
||||
smithay::wayland::pointer_constraints::PointerConstraint::Locked(_)
|
||||
) && constraint.is_active()
|
||||
})
|
||||
},
|
||||
)
|
||||
});
|
||||
let serial = SERIAL_COUNTER.next_serial();
|
||||
pointer.motion(
|
||||
self,
|
||||
None,
|
||||
&smithay::input::pointer::MotionEvent {
|
||||
location: self.pointer_location,
|
||||
serial,
|
||||
time: 0,
|
||||
},
|
||||
);
|
||||
pointer.frame(self);
|
||||
// X11/Wayland applications can leave the shared cursor set to Hidden
|
||||
// after the shell retakes pointer dispatch. Restore the normal desktop
|
||||
// cursor unless a client still owns an active pointer lock.
|
||||
if !pointer_locked {
|
||||
self.cursor_status = smithay::input::pointer::CursorImageStatus::default_named();
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward relative motion received from the web UI to the Wayland surface.
|
||||
pub fn handle_forwarded_relative_pointer_event(&mut self, window_name: &str, dx: f64, dy: f64) {
|
||||
use smithay::input::pointer::RelativeMotionEvent;
|
||||
use smithay::utils::{Logical, Point};
|
||||
|
||||
let window = match self.find_wayland_window(window_name) {
|
||||
Some(w) => w,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let surface = window
|
||||
.toplevel()
|
||||
.map(|t| t.wl_surface().clone())
|
||||
.or_else(|| window.x11_surface().and_then(|x| x.wl_surface().clone()));
|
||||
let Some(pointer) = self.seat.get_pointer() else {
|
||||
return;
|
||||
};
|
||||
let Some(window_location) = self.space.element_location(&window) else {
|
||||
return;
|
||||
};
|
||||
let pointer_in_window: Point<f64, Logical> =
|
||||
self.pointer_location - window_location.to_f64() + window.geometry().loc.to_f64();
|
||||
let Some((surface, surface_offset)) =
|
||||
window.surface_under(pointer_in_window, WindowSurfaceType::ALL)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(surface) = surface {
|
||||
if let Some(pointer) = self.seat.get_pointer() {
|
||||
let surface_origin: Point<f64, Logical> =
|
||||
window_location.to_f64() + surface_offset.to_f64();
|
||||
let event = RelativeMotionEvent {
|
||||
utime: 0,
|
||||
delta: (dx, dy).into(),
|
||||
delta_unaccel: (dx, dy).into(),
|
||||
};
|
||||
pointer.relative_motion(self, Some((surface, (0.0, 0.0).into())), &event);
|
||||
pointer.relative_motion(self, Some((surface, surface_origin)), &event);
|
||||
pointer.frame(self);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
///
|
||||
/// Implements `zwlr_screencopy_manager_v1` allowing clients like grim, OBS, etc.
|
||||
/// to request frame copies of outputs or output regions.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::{debug, info, warn};
|
||||
use wayland_protocols_wlr::screencopy::v1::server::{
|
||||
|
|
@ -10,8 +9,8 @@ use wayland_protocols_wlr::screencopy::v1::server::{
|
|||
zwlr_screencopy_manager_v1::{self, ZwlrScreencopyManagerV1},
|
||||
};
|
||||
use wayland_server::{
|
||||
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
|
||||
protocol::{wl_buffer::WlBuffer, wl_shm},
|
||||
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
|
||||
};
|
||||
|
||||
use crate::state::WoState;
|
||||
|
|
@ -124,7 +123,9 @@ impl Dispatch<ZwlrScreencopyManagerV1, ()> for WoState {
|
|||
send_buffer_info(&frame_resource, w, h);
|
||||
debug!(
|
||||
"CaptureOutput: {}x{}, cursor_overlay={}",
|
||||
w, h, overlay_cursor != 0
|
||||
w,
|
||||
h,
|
||||
overlay_cursor != 0
|
||||
);
|
||||
}
|
||||
zwlr_screencopy_manager_v1::Request::CaptureOutputRegion {
|
||||
|
|
@ -156,7 +157,11 @@ impl Dispatch<ZwlrScreencopyManagerV1, ()> for WoState {
|
|||
send_buffer_info(&frame_resource, rw as u32, rh as u32);
|
||||
debug!(
|
||||
"CaptureOutputRegion: ({},{}) {}x{}, cursor_overlay={}",
|
||||
rx, ry, rw, rh, overlay_cursor != 0
|
||||
rx,
|
||||
ry,
|
||||
rw,
|
||||
rh,
|
||||
overlay_cursor != 0
|
||||
);
|
||||
}
|
||||
zwlr_screencopy_manager_v1::Request::Destroy => {
|
||||
|
|
@ -172,20 +177,11 @@ fn send_buffer_info(frame: &ZwlrScreencopyFrameV1, width: u32, height: u32) {
|
|||
let stride = width * 4; // ARGB8888 / BGRx
|
||||
|
||||
// SHM buffer support (always available)
|
||||
frame.buffer(
|
||||
wl_shm::Format::Argb8888,
|
||||
width,
|
||||
height,
|
||||
stride,
|
||||
);
|
||||
frame.buffer(wl_shm::Format::Argb8888, width, height, stride);
|
||||
|
||||
// DMABUF support (v3)
|
||||
if frame.version() >= 3 {
|
||||
frame.linux_dmabuf(
|
||||
drm_fourcc::DrmFourcc::Argb8888 as u32,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
frame.linux_dmabuf(drm_fourcc::DrmFourcc::Argb8888 as u32, width, height);
|
||||
frame.buffer_done();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use crate::state::WoState;
|
|||
impl XdgDecorationHandler for WoState {
|
||||
fn new_decoration(&mut self, toplevel: ToplevelSurface) {
|
||||
tracing::info!("new_decoration: client attached xdg_decoration");
|
||||
self.metadata_dirty = true;
|
||||
|
||||
let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32);
|
||||
let suggest_w = (ow * 2 / 3).max(640).min(1600);
|
||||
|
|
@ -44,6 +45,7 @@ impl XdgDecorationHandler for WoState {
|
|||
self.ssd_windows.remove(&toplevel.wl_surface().id());
|
||||
}
|
||||
}
|
||||
self.metadata_dirty = true;
|
||||
toplevel.send_configure();
|
||||
}
|
||||
|
||||
|
|
@ -61,6 +63,7 @@ impl XdgDecorationHandler for WoState {
|
|||
}
|
||||
});
|
||||
self.ssd_windows.remove(&toplevel.wl_surface().id());
|
||||
self.metadata_dirty = true;
|
||||
toplevel.send_configure();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::ResizeEdge;
|
||||
use smithay::{
|
||||
delegate_xdg_shell,
|
||||
desktop::{PopupManager, Space, Window},
|
||||
reexports::wayland_server::{protocol::{wl_output, wl_seat}, Resource},
|
||||
reexports::wayland_server::{
|
||||
protocol::{wl_output, wl_seat},
|
||||
Resource,
|
||||
},
|
||||
utils::Serial,
|
||||
wayland::{
|
||||
compositor::with_states,
|
||||
|
|
@ -11,7 +15,6 @@ use smithay::{
|
|||
},
|
||||
},
|
||||
};
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::ResizeEdge;
|
||||
|
||||
use crate::state::WoState;
|
||||
|
||||
|
|
@ -31,7 +34,8 @@ impl XdgShellHandler for WoState {
|
|||
fn new_toplevel(&mut self, surface: ToplevelSurface) {
|
||||
tracing::info!("new_toplevel ENTER");
|
||||
let app_id = with_states(surface.wl_surface(), |states| {
|
||||
states.data_map
|
||||
states
|
||||
.data_map
|
||||
.get::<XdgToplevelSurfaceData>()
|
||||
.and_then(|d| d.lock().ok().and_then(|data| data.app_id.clone()))
|
||||
});
|
||||
|
|
@ -42,8 +46,10 @@ impl XdgShellHandler for WoState {
|
|||
let oid = surface.wl_surface().id();
|
||||
let stable_name = format!("wayland-{}", self.next_wayland_id);
|
||||
self.next_wayland_id += 1;
|
||||
self.wayland_window_names.insert(oid.clone(), stable_name.clone());
|
||||
self.wayland_name_to_id.insert(stable_name.clone(), oid.clone());
|
||||
self.wayland_window_names
|
||||
.insert(oid.clone(), stable_name.clone());
|
||||
self.wayland_name_to_id
|
||||
.insert(stable_name.clone(), oid.clone());
|
||||
tracing::info!(name = %stable_name, "assigned stable window name");
|
||||
// Mark this Wayland window as mapped/visible for metadata and frontend
|
||||
self.window_mapped.insert(stable_name.clone(), true);
|
||||
|
|
@ -63,7 +69,6 @@ impl XdgShellHandler for WoState {
|
|||
})
|
||||
});
|
||||
|
||||
|
||||
let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32);
|
||||
|
||||
let location = if let Some(ref parent_wl) = parent_surface {
|
||||
|
|
@ -115,7 +120,8 @@ impl XdgShellHandler for WoState {
|
|||
// absolute popup rectangle and clamp it back into [0, output_size].
|
||||
let parent_loc: Option<smithay::utils::Point<i32, smithay::utils::Logical>> =
|
||||
smithay::wayland::compositor::with_states(surface.wl_surface(), |states| {
|
||||
states.data_map
|
||||
states
|
||||
.data_map
|
||||
.get::<smithay::wayland::shell::xdg::XdgPopupSurfaceData>()
|
||||
.and_then(|d| d.lock().ok().and_then(|locked| locked.parent.clone()))
|
||||
})
|
||||
|
|
@ -151,7 +157,10 @@ impl XdgShellHandler for WoState {
|
|||
if let Err(e) = surface.send_configure() {
|
||||
tracing::warn!("failed to send initial popup configure: {e}");
|
||||
}
|
||||
if let Err(e) = self.popup_manager.track_popup(smithay::desktop::PopupKind::Xdg(surface)) {
|
||||
if let Err(e) = self
|
||||
.popup_manager
|
||||
.track_popup(smithay::desktop::PopupKind::Xdg(surface))
|
||||
{
|
||||
tracing::warn!("failed tracking popup: {e}");
|
||||
}
|
||||
}
|
||||
|
|
@ -171,7 +180,9 @@ impl XdgShellHandler for WoState {
|
|||
|
||||
fn move_request(&mut self, surface: ToplevelSurface, _seat: wl_seat::WlSeat, _serial: Serial) {
|
||||
// Find the window element for this surface and start tracking a move.
|
||||
let window = self.space.elements()
|
||||
let window = self
|
||||
.space
|
||||
.elements()
|
||||
.find(|w| w.toplevel().map(|t| t == &surface).unwrap_or(false))
|
||||
.cloned();
|
||||
|
||||
|
|
@ -192,7 +203,9 @@ impl XdgShellHandler for WoState {
|
|||
_serial: Serial,
|
||||
edges: ResizeEdge,
|
||||
) {
|
||||
let window = self.space.elements()
|
||||
let window = self
|
||||
.space
|
||||
.elements()
|
||||
.find(|w| w.toplevel().map(|t| t == &surface).unwrap_or(false))
|
||||
.cloned();
|
||||
|
||||
|
|
@ -210,28 +223,43 @@ impl XdgShellHandler for WoState {
|
|||
}
|
||||
}
|
||||
|
||||
fn grab(&mut self, _surface: PopupSurface, _seat: wl_seat::WlSeat, _serial: Serial) { }
|
||||
fn grab(&mut self, _surface: PopupSurface, _seat: wl_seat::WlSeat, _serial: Serial) {}
|
||||
|
||||
fn fullscreen_request(&mut self, surface: ToplevelSurface, _output: Option<wl_output::WlOutput>) {
|
||||
let window = self.space.elements()
|
||||
fn fullscreen_request(
|
||||
&mut self,
|
||||
surface: ToplevelSurface,
|
||||
_output: Option<wl_output::WlOutput>,
|
||||
) {
|
||||
let window = self
|
||||
.space
|
||||
.elements()
|
||||
.find(|w| w.toplevel().map(|t| t == &surface).unwrap_or(false))
|
||||
.cloned();
|
||||
|
||||
if let Some(window) = window {
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::State as XdgState;
|
||||
|
||||
let th = self.title_h_for_window(&window);
|
||||
let y_offset = th.max(PANEL_H);
|
||||
let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32);
|
||||
let content_h = (oh - y_offset).max(1);
|
||||
let name = self.wayland_window_name(&window).cloned();
|
||||
if let Some(ref name) = name {
|
||||
if !self.fullscreen_windows.contains(name) {
|
||||
let loc = self.space.element_location(&window).unwrap_or_default();
|
||||
let size = window.geometry().size;
|
||||
self.fullscreen_restore.insert(
|
||||
name.clone(),
|
||||
(loc.x, loc.y, size.w.max(1) as u32, size.h.max(1) as u32),
|
||||
);
|
||||
}
|
||||
self.fullscreen_windows.insert(name.clone());
|
||||
}
|
||||
|
||||
surface.with_pending_state(|state| {
|
||||
state.states.set(XdgState::Fullscreen);
|
||||
state.states.unset(XdgState::Maximized);
|
||||
state.size = Some((ow, content_h).into());
|
||||
state.size = Some((ow, oh).into());
|
||||
});
|
||||
surface.send_configure();
|
||||
self.space.map_element(window.clone(), (0, y_offset), false);
|
||||
self.space.map_element(window.clone(), (0, 0), false);
|
||||
self.metadata_dirty = true;
|
||||
|
||||
// Automatically give keyboard focus to fullscreen windows (important for games)
|
||||
|
|
@ -243,24 +271,33 @@ impl XdgShellHandler for WoState {
|
|||
}
|
||||
|
||||
fn unfullscreen_request(&mut self, surface: ToplevelSurface) {
|
||||
let window = self.space.elements()
|
||||
let window = self
|
||||
.space
|
||||
.elements()
|
||||
.find(|w| w.toplevel().map(|t| t == &surface).unwrap_or(false))
|
||||
.cloned();
|
||||
|
||||
if let Some(_window) = window {
|
||||
if let Some(window) = window {
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::State as XdgState;
|
||||
|
||||
let name = self.wayland_window_name(&window).cloned();
|
||||
let restore = name
|
||||
.as_ref()
|
||||
.and_then(|name| self.fullscreen_restore.remove(name));
|
||||
if let Some(ref name) = name {
|
||||
self.fullscreen_windows.remove(name);
|
||||
}
|
||||
let (x, y, width, height) = restore.unwrap_or_else(|| {
|
||||
let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32);
|
||||
let suggest_w = (ow * 2 / 3).max(640).min(1600);
|
||||
let suggest_h = (oh * 2 / 3).max(480).min(1000);
|
||||
((ow / 6), (oh / 6), (ow * 2 / 3) as u32, (oh * 2 / 3) as u32)
|
||||
});
|
||||
|
||||
surface.with_pending_state(|state| {
|
||||
state.states.unset(XdgState::Fullscreen);
|
||||
if state.size.is_none() {
|
||||
state.size = Some((suggest_w, suggest_h).into());
|
||||
}
|
||||
state.size = Some((width as i32, height as i32).into());
|
||||
});
|
||||
surface.send_configure();
|
||||
self.space.map_element(window, (x, y), false);
|
||||
self.metadata_dirty = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -273,6 +310,8 @@ impl XdgShellHandler for WoState {
|
|||
let oid = surface.wl_surface().id();
|
||||
if let Some(name) = self.wayland_window_names.remove(&oid) {
|
||||
self.wayland_name_to_id.remove(&name);
|
||||
self.fullscreen_restore.remove(&name);
|
||||
self.fullscreen_windows.remove(&name);
|
||||
if self.keyboard_window_focus.as_deref() == Some(&name) {
|
||||
self.keyboard_window_focus = None;
|
||||
}
|
||||
|
|
@ -293,11 +332,7 @@ impl XdgShellHandler for WoState {
|
|||
let to_remove = self
|
||||
.all_windows
|
||||
.iter()
|
||||
.find(|w| {
|
||||
w.toplevel()
|
||||
.map(|t| t == &surface)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.find(|w| w.toplevel().map(|t| t == &surface).unwrap_or(false))
|
||||
.cloned();
|
||||
|
||||
if let Some(window) = to_remove {
|
||||
|
|
@ -334,7 +369,6 @@ pub fn ensure_initial_configure(
|
|||
space: &Space<Window>,
|
||||
popups: &mut PopupManager,
|
||||
) {
|
||||
|
||||
if let Some(window) = space
|
||||
.elements()
|
||||
.find(|w| {
|
||||
|
|
|
|||
|
|
@ -3,20 +3,18 @@
|
|||
//! Implements [`XwmHandler`] and [`XWaylandShellHandler`] so that X11 clients
|
||||
//! running under XWayland can be managed by the compositor.
|
||||
|
||||
use smithay::reexports::wayland_server::Resource;
|
||||
use smithay::xwayland::xwm::X11Window;
|
||||
use smithay::{
|
||||
desktop::Window,
|
||||
utils::{Logical, Rectangle},
|
||||
wayland::xwayland_shell::{XWaylandShellHandler, XWaylandShellState},
|
||||
xwayland::xwm::{
|
||||
Reorder, ResizeEdge as X11ResizeEdge, X11Surface, X11Wm, XwmHandler, XwmId,
|
||||
},
|
||||
xwayland::xwm::{Reorder, ResizeEdge as X11ResizeEdge, X11Surface, X11Wm, XwmHandler, XwmId},
|
||||
};
|
||||
use smithay::xwayland::xwm::X11Window;
|
||||
use smithay::reexports::wayland_server::Resource;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::state::WoState;
|
||||
use crate::handlers::xdg_shell::PANEL_H;
|
||||
use crate::state::WoState;
|
||||
|
||||
impl XwmHandler for WoState {
|
||||
fn xwm_state(&mut self, _xwm: XwmId) -> &mut X11Wm {
|
||||
|
|
@ -51,7 +49,8 @@ impl XwmHandler for WoState {
|
|||
}
|
||||
|
||||
let stable_name = format!("x11-{}", window.window_id());
|
||||
self.x11_window_names.insert(window.window_id(), stable_name.clone());
|
||||
self.x11_window_names
|
||||
.insert(window.window_id(), stable_name.clone());
|
||||
|
||||
let parent_x11_id = window.is_transient_for();
|
||||
let geo = window.geometry();
|
||||
|
|
@ -122,10 +121,7 @@ impl XwmHandler for WoState {
|
|||
}
|
||||
|
||||
fn unmapped_window(&mut self, _xwm: XwmId, window: X11Surface) {
|
||||
info!(
|
||||
window_id = window.window_id(),
|
||||
"XWayland: window unmapped"
|
||||
);
|
||||
info!(window_id = window.window_id(), "XWayland: window unmapped");
|
||||
|
||||
if let Some(name) = self.x11_window_names.get(&window.window_id()).cloned() {
|
||||
if self.keyboard_window_focus.as_deref() == Some(&name) {
|
||||
|
|
@ -145,11 +141,15 @@ impl XwmHandler for WoState {
|
|||
}
|
||||
}
|
||||
|
||||
let to_remove = self.space.elements().find(|w| {
|
||||
let to_remove = self
|
||||
.space
|
||||
.elements()
|
||||
.find(|w| {
|
||||
w.x11_surface()
|
||||
.map(|x| x.window_id() == window.window_id())
|
||||
.unwrap_or(false)
|
||||
}).cloned();
|
||||
})
|
||||
.cloned();
|
||||
|
||||
if let Some(w) = to_remove {
|
||||
self.space.unmap_elem(&w);
|
||||
|
|
@ -160,10 +160,7 @@ impl XwmHandler for WoState {
|
|||
}
|
||||
|
||||
fn destroyed_window(&mut self, _xwm: XwmId, window: X11Surface) {
|
||||
info!(
|
||||
window_id = window.window_id(),
|
||||
"XWayland: window destroyed"
|
||||
);
|
||||
info!(window_id = window.window_id(), "XWayland: window destroyed");
|
||||
|
||||
if let Some(name) = self.x11_window_names.get(&window.window_id()).cloned() {
|
||||
if self.keyboard_window_focus.as_deref() == Some(&name) {
|
||||
|
|
@ -183,13 +180,20 @@ impl XwmHandler for WoState {
|
|||
}
|
||||
}
|
||||
|
||||
self.x11_window_names.remove(&window.window_id());
|
||||
if let Some(name) = self.x11_window_names.remove(&window.window_id()) {
|
||||
self.fullscreen_restore.remove(&name);
|
||||
self.fullscreen_windows.remove(&name);
|
||||
}
|
||||
|
||||
let to_remove = self.space.elements().find(|w| {
|
||||
let to_remove = self
|
||||
.space
|
||||
.elements()
|
||||
.find(|w| {
|
||||
w.x11_surface()
|
||||
.map(|x| x.window_id() == window.window_id())
|
||||
.unwrap_or(false)
|
||||
}).cloned();
|
||||
})
|
||||
.cloned();
|
||||
if let Some(w) = to_remove {
|
||||
self.space.unmap_elem(&w);
|
||||
self.all_windows.retain(|win| win != &w);
|
||||
|
|
@ -219,7 +223,8 @@ impl XwmHandler for WoState {
|
|||
_window: X11Surface,
|
||||
_geometry: Rectangle<i32, Logical>,
|
||||
_above: Option<X11Window>,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
fn resize_request(
|
||||
&mut self,
|
||||
|
|
@ -227,7 +232,8 @@ impl XwmHandler for WoState {
|
|||
_window: X11Surface,
|
||||
_button: u32,
|
||||
_resize_edge: X11ResizeEdge,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
fn move_request(&mut self, _xwm: XwmId, _window: X11Surface, _button: u32) {}
|
||||
|
||||
|
|
@ -242,32 +248,36 @@ impl XwmHandler for WoState {
|
|||
return;
|
||||
}
|
||||
|
||||
let th = if let Some(wl_surface) = window.wl_surface() {
|
||||
if self.ssd_windows.contains(&wl_surface.id()) {
|
||||
30
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let y_offset = th.max(PANEL_H);
|
||||
let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32);
|
||||
let content_h = (oh - y_offset).max(1);
|
||||
|
||||
#[allow(deprecated)]
|
||||
if let Err(e) = window.configure(Some(Rectangle::from_loc_and_size((0, y_offset), (ow, content_h)))) {
|
||||
warn!("XWayland: fullscreen configure failed: {e}");
|
||||
}
|
||||
|
||||
let win = self.space.elements().find(|w| {
|
||||
let name = self.x11_window_names.get(&window.window_id()).cloned();
|
||||
let win = self
|
||||
.space
|
||||
.elements()
|
||||
.find(|w| {
|
||||
w.x11_surface()
|
||||
.map(|x| x.window_id() == window.window_id())
|
||||
.unwrap_or(false)
|
||||
}).cloned();
|
||||
})
|
||||
.cloned();
|
||||
if let (Some(name), Some(win)) = (name.as_ref(), win.as_ref()) {
|
||||
if !self.fullscreen_windows.contains(name) {
|
||||
let loc = self.space.element_location(win).unwrap_or_default();
|
||||
let size = window.geometry().size;
|
||||
self.fullscreen_restore.insert(
|
||||
name.clone(),
|
||||
(loc.x, loc.y, size.w.max(1) as u32, size.h.max(1) as u32),
|
||||
);
|
||||
}
|
||||
self.fullscreen_windows.insert(name.clone());
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
if let Err(e) = window.configure(Some(Rectangle::from_loc_and_size((0, 0), (ow, oh)))) {
|
||||
warn!("XWayland: fullscreen configure failed: {e}");
|
||||
}
|
||||
|
||||
if let Some(w) = win {
|
||||
self.space.map_element(w, (0, y_offset), false);
|
||||
self.space.map_element(w, (0, 0), false);
|
||||
}
|
||||
|
||||
// Automatically give keyboard focus to fullscreen windows (important for games)
|
||||
|
|
@ -297,14 +307,37 @@ impl XwmHandler for WoState {
|
|||
return;
|
||||
}
|
||||
|
||||
let name = self.x11_window_names.get(&window.window_id()).cloned();
|
||||
let restore = name
|
||||
.as_ref()
|
||||
.and_then(|name| self.fullscreen_restore.remove(name));
|
||||
if let Some(ref name) = name {
|
||||
self.fullscreen_windows.remove(name);
|
||||
}
|
||||
let (x, y, width, height) = restore.unwrap_or_else(|| {
|
||||
let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32);
|
||||
let suggest_w = (ow * 2 / 3).max(640).min(1600);
|
||||
let suggest_h = (oh * 2 / 3).max(480).min(1000);
|
||||
(ow / 6, oh / 6, (ow * 2 / 3) as u32, (oh * 2 / 3) as u32)
|
||||
});
|
||||
|
||||
#[allow(deprecated)]
|
||||
if let Err(e) = window.configure(Some(Rectangle::from_loc_and_size((0, 0), (suggest_w, suggest_h)))) {
|
||||
if let Err(e) = window.configure(Some(Rectangle::from_loc_and_size(
|
||||
(x, y),
|
||||
(width as i32, height as i32),
|
||||
))) {
|
||||
warn!("XWayland: unfullscreen configure failed: {e}");
|
||||
}
|
||||
let mapped_window = self
|
||||
.space
|
||||
.elements()
|
||||
.find(|w| {
|
||||
w.x11_surface()
|
||||
.map(|x| x.window_id() == window.window_id())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned();
|
||||
if let Some(win) = mapped_window {
|
||||
self.space.map_element(win, (x, y), false);
|
||||
}
|
||||
self.metadata_dirty = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1517
src/main.rs
1517
src/main.rs
File diff suppressed because it is too large
Load diff
472
src/nested.rs
472
src/nested.rs
|
|
@ -22,14 +22,12 @@ use smithay::{
|
|||
},
|
||||
output::{Mode as OutputMode, Output, PhysicalProperties, Scale, Subpixel},
|
||||
reexports::wayland_server::{Display, ListeningSocket},
|
||||
utils::{
|
||||
Logical, Physical, Point, Rectangle,
|
||||
Scale as ScaleF, Size, Transform,
|
||||
},
|
||||
utils::{Logical, Physical, Point, Rectangle, Scale as ScaleF, Size, Transform},
|
||||
wayland::{seat::WaylandFocus, xdg_activation::XdgActivationHandler},
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
os::unix::fs::FileTypeExt,
|
||||
path::Path,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
|
@ -67,6 +65,93 @@ fn find_render_node() -> DrmNode {
|
|||
.unwrap_or_else(|_| panic!("Could not find or create any render node"))
|
||||
}
|
||||
|
||||
fn refresh_millihz(backend: &winit::WinitGraphicsBackend<GlesRenderer>) -> u32 {
|
||||
backend
|
||||
.window()
|
||||
.current_monitor()
|
||||
.and_then(|monitor| monitor.refresh_rate_millihertz())
|
||||
.filter(|rate| *rate > 0)
|
||||
.unwrap_or(60_000)
|
||||
}
|
||||
|
||||
fn frame_duration(refresh_millihz: u32) -> Duration {
|
||||
Duration::from_secs_f64(1_000.0 / f64::from(refresh_millihz.max(1)))
|
||||
}
|
||||
|
||||
fn host_wayland_display() -> Option<std::ffi::OsString> {
|
||||
let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR")?;
|
||||
let runtime_path = Path::new(&runtime_dir);
|
||||
|
||||
if let Some(display) = std::env::var_os("WAYLAND_DISPLAY") {
|
||||
let socket = Path::new(&display);
|
||||
let socket_path = if socket.is_absolute() {
|
||||
socket.to_path_buf()
|
||||
} else {
|
||||
runtime_path.join(socket)
|
||||
};
|
||||
if socket_path.metadata().is_ok_and(|metadata| metadata.file_type().is_socket()) {
|
||||
return Some(display);
|
||||
}
|
||||
warn!(path = %socket_path.display(), "WAYLAND_DISPLAY does not name an accessible socket; searching runtime directory");
|
||||
}
|
||||
|
||||
let mut sockets = std::fs::read_dir(runtime_path)
|
||||
.ok()?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| {
|
||||
entry.file_name().to_string_lossy().starts_with("wayland-")
|
||||
&& entry.file_type().is_ok_and(|kind| kind.is_socket())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
sockets.sort_by_key(|entry| entry.file_name());
|
||||
sockets.first().map(|entry| entry.file_name())
|
||||
}
|
||||
|
||||
fn spawn_nested_electron_windows(
|
||||
state: &mut WoState,
|
||||
config: &Config,
|
||||
app_dir: &Path,
|
||||
render_node: &str,
|
||||
width: u32,
|
||||
height: u32,
|
||||
ozone_platform: &str,
|
||||
) {
|
||||
for root_cfg in &config.root {
|
||||
let mut full = root_cfg.clone();
|
||||
full.system_shell = true;
|
||||
full.x = 0;
|
||||
full.y = 0;
|
||||
full.width = width;
|
||||
full.height = height;
|
||||
full.z_order = i32::MIN / 2;
|
||||
match ElectronProcess::spawn(
|
||||
&full,
|
||||
&config.compositor.electron_path,
|
||||
app_dir,
|
||||
&config.compositor.ipc_socket,
|
||||
render_node,
|
||||
ozone_platform,
|
||||
) {
|
||||
Ok(proc) => state.electron_processes.push(proc),
|
||||
Err(e) => error!(window = %full.name, "failed to spawn Electron root: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
for win_cfg in &config.windows {
|
||||
match ElectronProcess::spawn(
|
||||
win_cfg,
|
||||
&config.compositor.electron_path,
|
||||
app_dir,
|
||||
&config.compositor.ipc_socket,
|
||||
render_node,
|
||||
ozone_platform,
|
||||
) {
|
||||
Ok(proc) => state.electron_processes.push(proc),
|
||||
Err(e) => error!(window = %win_cfg.name, "failed to spawn Electron: {e:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_egl_context_or_surface_lost(err: &str) -> bool {
|
||||
let lowered = err.to_ascii_lowercase();
|
||||
lowered.contains("context has been lost")
|
||||
|
|
@ -77,15 +162,15 @@ fn is_egl_context_or_surface_lost(err: &str) -> bool {
|
|||
|| lowered.contains("failed to allocate resources")
|
||||
}
|
||||
|
||||
pub fn run_nested(config: Config) -> Result<()> {
|
||||
pub fn run_nested(mut config: Config) -> Result<()> {
|
||||
unsafe {
|
||||
libc::signal(libc::SIGPIPE, libc::SIG_IGN);
|
||||
}
|
||||
|
||||
info!("Starting nested compositor mode");
|
||||
|
||||
// Apply configured environment variables (e.g. DISPLAY=:0) early so that
|
||||
// all children inherit them. XWayland will override DISPLAY later.
|
||||
// Apply explicit environment overrides early. DISPLAY is set only after
|
||||
// this compositor's XWayland server is ready.
|
||||
config.apply_environment();
|
||||
|
||||
if config.compositor.exit_on_escape {
|
||||
|
|
@ -101,7 +186,28 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
.with_context(|| format!("binding Wayland socket {}", config.compositor.socket_name))?;
|
||||
info!(socket = %config.compositor.socket_name, "Wayland socket created");
|
||||
|
||||
let (mut backend, mut winit_evt_loop) = match winit::init::<GlesRenderer>() {
|
||||
// Some session launchers and old configs provide DISPLAY but omit
|
||||
// WAYLAND_DISPLAY. Discover the host compositor socket in that case so
|
||||
// nested Wo uses a native Wayland host window instead of failing XOpenDisplay
|
||||
// and unnecessarily dropping into DRM/KMS root mode.
|
||||
let host_display = std::env::var_os("DISPLAY");
|
||||
let host_wayland = host_wayland_display();
|
||||
if let Some(wayland_display) = host_wayland {
|
||||
std::env::set_var("WAYLAND_DISPLAY", &wayland_display);
|
||||
info!(socket = %wayland_display.to_string_lossy(), "Using host Wayland display for nested startup");
|
||||
if host_display.is_some() {
|
||||
info!("Temporarily hiding DISPLAY so winit selects the host Wayland backend");
|
||||
}
|
||||
std::env::remove_var("DISPLAY");
|
||||
} else {
|
||||
info!("No host Wayland socket found; nested startup will use X11 if DISPLAY is available");
|
||||
}
|
||||
let backend_result = winit::init::<GlesRenderer>();
|
||||
if let Some(display) = host_display {
|
||||
std::env::set_var("DISPLAY", display);
|
||||
}
|
||||
|
||||
let (mut backend, mut winit_evt_loop) = match backend_result {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
error!("Failed to create winit backend: {:?}", e);
|
||||
|
|
@ -124,9 +230,10 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
},
|
||||
);
|
||||
|
||||
let mut refresh_rate = refresh_millihz(&backend);
|
||||
let output_mode = OutputMode {
|
||||
size: (width as i32, height as i32).into(),
|
||||
refresh: 60_000,
|
||||
refresh: refresh_rate as i32,
|
||||
};
|
||||
|
||||
output.change_current_state(
|
||||
|
|
@ -164,18 +271,34 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
dmabuf_formats: Some(dmabuf_formats),
|
||||
};
|
||||
|
||||
crate::spawn_immediate_autostarts(&mut config);
|
||||
let mut state = WoState::new(&mut display, config.clone(), render_node, backend_data);
|
||||
|
||||
let (ipc, mut msg_rx) =
|
||||
ElectronIpc::listen(&config.compositor.ipc_socket).context("IPC socket")?;
|
||||
state.electron_ipc = Some(ipc);
|
||||
|
||||
let app_dir = crate::electron::app_dir();
|
||||
// Nested mode has no DRM card; use the first available render node.
|
||||
let nested_render_node = std::fs::read_dir("/dev/dri")
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|e| e.filter_map(Result::ok))
|
||||
.find(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.map_or(false, |n| n.starts_with("renderD"))
|
||||
})
|
||||
.map(|e| e.path().to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "/dev/dri/renderD128".to_string());
|
||||
|
||||
let mut calloop_loop =
|
||||
calloop::EventLoop::<WoState>::try_new().context("calloop EventLoop creation")?;
|
||||
let calloop_handle = calloop_loop.handle();
|
||||
|
||||
state.xwayland_shell_state = Some(XWaylandShellState::new::<WoState>(&dh));
|
||||
|
||||
let mut xwayland_source_registered = false;
|
||||
match XWayland::spawn(
|
||||
&dh,
|
||||
None, // auto-detect display number
|
||||
|
|
@ -189,6 +312,10 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
let wm_handle = calloop_handle.clone();
|
||||
let xw_client = std::rc::Rc::new(std::cell::RefCell::new(Some(xw_client)));
|
||||
let xw_client_cb = xw_client.clone();
|
||||
let startup_config = config.clone();
|
||||
let startup_app_dir = app_dir.clone();
|
||||
let startup_render_node = nested_render_node.clone();
|
||||
let mut electron_started = false;
|
||||
|
||||
if let Err(e) =
|
||||
calloop_handle.insert_source(xwayland, move |event, _, state: &mut WoState| {
|
||||
|
|
@ -216,15 +343,40 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
Err(e) => error!("X11Wm::start_wm failed: {e:#}"),
|
||||
}
|
||||
}
|
||||
if !electron_started {
|
||||
spawn_nested_electron_windows(
|
||||
state,
|
||||
&startup_config,
|
||||
&startup_app_dir,
|
||||
&startup_render_node,
|
||||
width as u32,
|
||||
height as u32,
|
||||
"wayland",
|
||||
);
|
||||
electron_started = true;
|
||||
}
|
||||
}
|
||||
XWaylandEvent::Error => {
|
||||
warn!("XWayland server encountered an error during startup");
|
||||
if !electron_started {
|
||||
spawn_nested_electron_windows(
|
||||
state,
|
||||
&startup_config,
|
||||
&startup_app_dir,
|
||||
&startup_render_node,
|
||||
width as u32,
|
||||
height as u32,
|
||||
"wayland",
|
||||
);
|
||||
electron_started = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
{
|
||||
error!("Failed to register XWayland event source: {e:#}");
|
||||
} else {
|
||||
xwayland_source_registered = true;
|
||||
info!("XWayland server starting");
|
||||
}
|
||||
}
|
||||
|
|
@ -236,47 +388,16 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
std::env::set_var("WAYLAND_DISPLAY", &config.compositor.socket_name);
|
||||
info!("Set WAYLAND_DISPLAY={}", config.compositor.socket_name);
|
||||
crate::state::propagate_environment(&["WAYLAND_DISPLAY"]);
|
||||
|
||||
let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("electron");
|
||||
// Nested mode has no DRM card; use the first available render node.
|
||||
let nested_render_node = std::fs::read_dir("/dev/dri")
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|e| e.filter_map(Result::ok))
|
||||
.find(|e| e.file_name().to_str().map_or(false, |n| n.starts_with("renderD")))
|
||||
.map(|e| e.path().to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "/dev/dri/renderD128".to_string());
|
||||
|
||||
for root_cfg in &config.root {
|
||||
let mut full = root_cfg.clone();
|
||||
full.x = 0;
|
||||
full.y = 0;
|
||||
full.width = width as u32;
|
||||
full.height = height as u32;
|
||||
full.z_order = std::i32::MIN / 2;
|
||||
match ElectronProcess::spawn(
|
||||
&full,
|
||||
&config.compositor.electron_path,
|
||||
if !xwayland_source_registered {
|
||||
spawn_nested_electron_windows(
|
||||
&mut state,
|
||||
&config,
|
||||
&app_dir,
|
||||
&config.compositor.ipc_socket,
|
||||
&nested_render_node,
|
||||
) {
|
||||
Ok(proc) => state.electron_processes.push(proc),
|
||||
Err(e) => error!(window = %full.name, "failed to spawn Electron root: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
for win_cfg in &config.windows {
|
||||
match ElectronProcess::spawn(
|
||||
win_cfg,
|
||||
&config.compositor.electron_path,
|
||||
&app_dir,
|
||||
&config.compositor.ipc_socket,
|
||||
&nested_render_node,
|
||||
) {
|
||||
Ok(proc) => state.electron_processes.push(proc),
|
||||
Err(e) => error!(window = %win_cfg.name, "failed to spawn Electron: {e:#}"),
|
||||
}
|
||||
width as u32,
|
||||
height as u32,
|
||||
"wayland",
|
||||
);
|
||||
}
|
||||
|
||||
let mut autostart_tasks: Vec<(crate::config::AutostartConfig, std::time::Instant)> = Vec::new();
|
||||
|
|
@ -287,18 +408,20 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
}
|
||||
|
||||
let bg = Color32F::from(config.compositor.background);
|
||||
let frame_time = Duration::from_millis(16);
|
||||
let mut frame_time = frame_duration(refresh_rate);
|
||||
let mut last_render = std::time::Instant::now();
|
||||
let event_start_time = std::time::Instant::now();
|
||||
|
||||
let mut gles_tex_cache: HashMap<String, GlesTexture> = HashMap::new();
|
||||
let mut offscreen_tex_cache: HashMap<String, (GlesTexture, i32, i32)> = HashMap::new();
|
||||
let mut logged_first_frame_import = false;
|
||||
let mut logged_first_texture_draw = false;
|
||||
let mut logged_first_output_submit = false;
|
||||
use crate::electron::ElectronFrame;
|
||||
let mut latest_frames: HashMap<String, ElectronFrame> = HashMap::new();
|
||||
let mut last_wayland_elem_count: usize = 0;
|
||||
// Frame tracking for backpressure management
|
||||
let mut frame_timestamps: HashMap<String, Instant> = HashMap::new();
|
||||
let _max_frame_age = Duration::from_millis(33); // Drop frames older than 2 frames @60Hz
|
||||
// Track previous cursor position to detect movement
|
||||
let mut last_cursor_pos: (f64, f64) = (0.0, 0.0);
|
||||
|
||||
|
|
@ -315,6 +438,22 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
break;
|
||||
}
|
||||
|
||||
let current_refresh = refresh_millihz(&backend);
|
||||
if current_refresh != refresh_rate {
|
||||
refresh_rate = current_refresh;
|
||||
frame_time = frame_duration(refresh_rate);
|
||||
let mode = OutputMode {
|
||||
size: (state.output_size.0 as i32, state.output_size.1 as i32).into(),
|
||||
refresh: refresh_rate as i32,
|
||||
};
|
||||
output.change_current_state(Some(mode), None, None, None);
|
||||
output.set_preferred(mode);
|
||||
info!(
|
||||
refresh_hz = refresh_rate as f64 / 1000.0,
|
||||
"nested display refresh changed"
|
||||
);
|
||||
}
|
||||
|
||||
let winit_t0 = std::time::Instant::now();
|
||||
let _dispatch_result = winit_evt_loop.dispatch_new_events(|event| {
|
||||
match event {
|
||||
|
|
@ -322,7 +461,7 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
info!("Window resized to {}x{}", size.w, size.h);
|
||||
let new_mode = OutputMode {
|
||||
size: (size.w as i32, size.h as i32).into(),
|
||||
refresh: 60_000,
|
||||
refresh: refresh_rate as i32,
|
||||
};
|
||||
output.change_current_state(Some(new_mode), None, None, None);
|
||||
}
|
||||
|
|
@ -409,6 +548,7 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
}
|
||||
state.debug_new_toplevel = false;
|
||||
|
||||
let dispatch_elapsed = dispatch_t0.elapsed();
|
||||
let calloop_t0 = std::time::Instant::now();
|
||||
if let Err(e) = calloop_loop.dispatch(Duration::ZERO, &mut state) {
|
||||
warn!("calloop dispatch error: {e}");
|
||||
|
|
@ -426,7 +566,6 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
);
|
||||
last_wayland_elem_count = cur_wayland_count;
|
||||
}
|
||||
let dispatch_elapsed = dispatch_t0.elapsed();
|
||||
if dispatch_elapsed.as_millis() > 50 {
|
||||
warn!(
|
||||
"dispatch_clients+flush took {}ms!",
|
||||
|
|
@ -450,7 +589,8 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
| ElectronMessage::ForwardedKeyboard { .. }
|
||||
| ElectronMessage::ForwardedPointerButton { .. }
|
||||
| ElectronMessage::ForwardedRelativePointer { .. }
|
||||
| ElectronMessage::ForwardedPointerScroll { .. } => {
|
||||
| ElectronMessage::ForwardedPointerScroll { .. }
|
||||
| ElectronMessage::ForwardedPointerLeave { .. } => {
|
||||
input_batch.push(msg);
|
||||
input_events_pending = true;
|
||||
}
|
||||
|
|
@ -472,16 +612,22 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
|
||||
// Drop frames that are too old (backpressure)
|
||||
if let Some(last_ts) = frame_timestamps.get(&nm) {
|
||||
if now.duration_since(*last_ts) < Duration::from_millis(8) {
|
||||
// Skip frame if less than 8ms since last update (>120fps throttle)
|
||||
if now.duration_since(*last_ts) < frame_time {
|
||||
trace!("Dropping frame {} due to backpressure", nm);
|
||||
if let Some(ref ipc) = state.electron_ipc {
|
||||
let _ = ipc.send_frame_ack(&nm, frame.seq);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
frame_timestamps.insert(nm.clone(), now);
|
||||
// Only keep the latest frame per window
|
||||
latest_frames.insert(nm, frame);
|
||||
if let Some(stale) = latest_frames.insert(nm.clone(), frame) {
|
||||
if let Some(ref ipc) = state.electron_ipc {
|
||||
let _ = ipc.send_frame_ack(&nm, stale.seq);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -533,6 +679,7 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
Ok(status) => {
|
||||
let name = state.electron_processes[idx].name.clone();
|
||||
info!(window = %name, pid = pid, "Electron process exited: {:?}", status);
|
||||
state.current_frame_seq.remove(&name);
|
||||
let mut proc = state.electron_processes.remove(idx);
|
||||
let _ = proc.child.try_wait();
|
||||
state.metadata_dirty = true;
|
||||
|
|
@ -609,19 +756,37 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
|
||||
for (_, frame) in latest_frames.drain() {
|
||||
let win_name = frame.name.clone();
|
||||
let frame_seq = frame.seq;
|
||||
let (width, height) = (frame.width, frame.height);
|
||||
let mut imported = false;
|
||||
|
||||
// Import each new frame - each has unique pixel data even if size unchanged
|
||||
match import_electron_frame(renderer, frame) {
|
||||
Ok((tex, cached)) => {
|
||||
trace!("Frame imported for {} ({}x{})", win_name, width, height);
|
||||
if !logged_first_frame_import {
|
||||
info!(window = %win_name, width, height, seq = frame_seq, "first Electron frame imported as a DMA-BUF texture");
|
||||
logged_first_frame_import = true;
|
||||
}
|
||||
state.texture_cache.insert_dmabuf(cached);
|
||||
gles_tex_cache.insert(win_name.clone(), tex.texture);
|
||||
imported = true;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("DMABUF import failed for {}: {e:#}", win_name);
|
||||
}
|
||||
}
|
||||
if imported {
|
||||
if let Some(previous_seq) =
|
||||
state.current_frame_seq.insert(win_name.clone(), frame_seq)
|
||||
{
|
||||
if let Some(ref ipc) = state.electron_ipc {
|
||||
let _ = ipc.send_frame_ack(&win_name, previous_seq);
|
||||
}
|
||||
}
|
||||
} else if let Some(ref ipc) = state.electron_ipc {
|
||||
let _ = ipc.send_frame_ack(&win_name, frame_seq);
|
||||
}
|
||||
}
|
||||
|
||||
// Evict textures for windows no longer in the DMABUF cache.
|
||||
|
|
@ -633,15 +798,22 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
if frame_dirty {
|
||||
// Prepare cursor elements before starting the frame
|
||||
let cursor_elements: Vec<WaylandSurfaceRenderElement<GlesRenderer>> =
|
||||
if let smithay::input::pointer::CursorImageStatus::Surface(ref cursor_surface) = state.cursor_status {
|
||||
use smithay::backend::renderer::element::{surface::render_elements_from_surface_tree, Kind};
|
||||
if let smithay::input::pointer::CursorImageStatus::Surface(ref cursor_surface) =
|
||||
state.cursor_status
|
||||
{
|
||||
use smithay::backend::renderer::element::{
|
||||
surface::render_elements_from_surface_tree, Kind,
|
||||
};
|
||||
use smithay::utils::Scale as ScaleF;
|
||||
|
||||
let scale = ScaleF::from(1.0);
|
||||
render_elements_from_surface_tree(
|
||||
renderer,
|
||||
cursor_surface,
|
||||
(state.pointer_location.x as i32, state.pointer_location.y as i32),
|
||||
(
|
||||
state.pointer_location.x as i32,
|
||||
state.pointer_location.y as i32,
|
||||
),
|
||||
scale,
|
||||
1.0,
|
||||
Kind::Cursor,
|
||||
|
|
@ -651,11 +823,23 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
};
|
||||
|
||||
// Load themed cursor texture for Named cursor status
|
||||
let themed_cursor = if let smithay::input::pointer::CursorImageStatus::Named(ref icon) = state.cursor_status {
|
||||
state.cursor_theme_manager.get_cursor(icon.name(), renderer).cloned()
|
||||
let themed_cursor =
|
||||
if let smithay::input::pointer::CursorImageStatus::Named(ref icon) =
|
||||
state.cursor_status
|
||||
{
|
||||
state
|
||||
.cursor_theme_manager
|
||||
.get_cursor(icon.name(), renderer)
|
||||
.cloned()
|
||||
} else if cursor_elements.is_empty() {
|
||||
if !matches!(state.cursor_status, smithay::input::pointer::CursorImageStatus::Hidden) {
|
||||
state.cursor_theme_manager.get_cursor("default", renderer).cloned()
|
||||
if !matches!(
|
||||
state.cursor_status,
|
||||
smithay::input::pointer::CursorImageStatus::Hidden
|
||||
) {
|
||||
state
|
||||
.cursor_theme_manager
|
||||
.get_cursor("default", renderer)
|
||||
.cloned()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
|
@ -716,7 +900,12 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
1.0,
|
||||
) {
|
||||
warn!("Failed to render texture for {}: {}", win.name, e);
|
||||
} else if !logged_first_texture_draw {
|
||||
info!(window = %win.name, x = win.x, y = win.y, output_width = size.w, output_height = size.h, "first Electron texture rendered into the output frame");
|
||||
logged_first_texture_draw = true;
|
||||
}
|
||||
} else if win.name == "main" && !logged_first_frame_import {
|
||||
trace!(window = %win.name, mapped, "desktop root has no imported texture yet");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -829,11 +1018,14 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
|
||||
// Extract Wayland damage rects for partial-update optimization.
|
||||
// Empty damage = full frame (surfaces in dirty list always get exported).
|
||||
let damage_rects: Vec<crate::state::DamageRect> = with_states(&surface, |states| {
|
||||
let damage_rects: Vec<crate::state::DamageRect> =
|
||||
with_states(&surface, |states| {
|
||||
let mut attrs = states.cached_state.get::<SurfaceAttributes>();
|
||||
let current = attrs.current();
|
||||
current.damage.iter().map(|d| {
|
||||
match d {
|
||||
current
|
||||
.damage
|
||||
.iter()
|
||||
.map(|d| match d {
|
||||
Damage::Buffer(rect) => crate::state::DamageRect {
|
||||
x: rect.loc.x,
|
||||
y: rect.loc.y,
|
||||
|
|
@ -849,8 +1041,8 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
height: rect.size.h * scale,
|
||||
}
|
||||
}
|
||||
}
|
||||
}).collect()
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
// Calculate bbox (bounding box) for CSD windows with shadows/decorations
|
||||
|
|
@ -914,8 +1106,7 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
let src = unsafe { std::slice::from_raw_parts(ptr, pixel_bytes) };
|
||||
|
||||
// Determine whether to do a partial (damage-rect) or full copy.
|
||||
let do_partial = !damage_rects.is_empty()
|
||||
&& damage_rects.len() < 64;
|
||||
let do_partial = !damage_rects.is_empty() && damage_rects.len() < 64;
|
||||
|
||||
if do_partial {
|
||||
// Partial update: only copy damaged rows with BGRA→RGBA swap
|
||||
|
|
@ -972,13 +1163,18 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
let rss_data = states.data_map.get::<RendererSurfaceStateUserData>();
|
||||
rss_data
|
||||
.and_then(|d| d.lock().ok())
|
||||
.and_then(|rss| rss.buffer().map(|b| {
|
||||
.and_then(|rss| {
|
||||
rss.buffer().map(|b| {
|
||||
let mut r = (0u32, 0u32);
|
||||
let _ = with_buffer_contents(&b, |_ptr, _len, spec| {
|
||||
r = (spec.width.max(0) as u32, spec.height.max(0) as u32);
|
||||
r = (
|
||||
spec.width.max(0) as u32,
|
||||
spec.height.max(0) as u32,
|
||||
);
|
||||
});
|
||||
r
|
||||
}))
|
||||
})
|
||||
})
|
||||
.unwrap_or((w, h))
|
||||
});
|
||||
|
||||
|
|
@ -1107,7 +1303,6 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
state.dirty_surfaces.clear();
|
||||
} // inner bind scope ends here, backend borrow is released
|
||||
|
||||
|
||||
let did_bind = render_ok;
|
||||
|
||||
if did_bind {
|
||||
|
|
@ -1124,6 +1319,10 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
}
|
||||
} else {
|
||||
trace!("Frame submitted successfully");
|
||||
if !logged_first_output_submit {
|
||||
info!("first nested output frame submitted successfully");
|
||||
logged_first_output_submit = true;
|
||||
}
|
||||
}
|
||||
let submit_ms = submit_t0.elapsed().as_millis();
|
||||
if submit_ms > 50 {
|
||||
|
|
@ -1154,7 +1353,11 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
// some of the budget; sleep the rest so the next iteration starts close
|
||||
// to the target frame boundary.
|
||||
let elapsed = loop_t0.elapsed();
|
||||
let target = if input_events_pending { Duration::from_millis(8) } else { frame_time };
|
||||
let target = if input_events_pending {
|
||||
Duration::from_millis(8)
|
||||
} else {
|
||||
frame_time
|
||||
};
|
||||
if elapsed < target {
|
||||
let remaining = target - elapsed;
|
||||
// Don't sleep more than 4ms to stay responsive to new Wayland/IPC events
|
||||
|
|
@ -1164,7 +1367,15 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
// Log if the entire loop iteration was slow.
|
||||
let total_loop_ms = loop_t0.elapsed().as_millis();
|
||||
if total_loop_ms > 50 {
|
||||
warn!("SLOW LOOP iter={} total={}ms", loop_iter, total_loop_ms);
|
||||
warn!(
|
||||
"SLOW LOOP iter={} total={}ms wayland_dispatch={}ms calloop={}ms electron_messages={}ms render_callbacks={}ms",
|
||||
loop_iter,
|
||||
total_loop_ms,
|
||||
dispatch_elapsed.as_millis(),
|
||||
calloop_ms,
|
||||
emsg_ms,
|
||||
render_total_ms,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1189,7 +1400,11 @@ fn execute_autostart_task(
|
|||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|e| e.filter_map(Result::ok))
|
||||
.find(|e| e.file_name().to_str().map_or(false, |n| n.starts_with("renderD")))
|
||||
.find(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.map_or(false, |n| n.starts_with("renderD"))
|
||||
})
|
||||
.map(|e| e.path().to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "/dev/dri/renderD128".to_string());
|
||||
match ElectronProcess::spawn(
|
||||
|
|
@ -1198,23 +1413,21 @@ fn execute_autostart_task(
|
|||
app_dir,
|
||||
&state.config.compositor.ipc_socket,
|
||||
&render_node,
|
||||
"wayland",
|
||||
) {
|
||||
Ok(proc) => state.electron_processes.push(proc),
|
||||
Err(e) => error!(window = %name, "autostart spawn failed: {e:#}"),
|
||||
}
|
||||
}
|
||||
} else if cmd == "wo://portal/start" {
|
||||
let portal_bin = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join("wo-portal")))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("wo-portal"));
|
||||
let portal_bin = crate::companion_binary("wo-portal");
|
||||
match std::process::Command::new(&portal_bin).spawn() {
|
||||
Ok(_) => info!("Spawned portal service: {:?}", portal_bin),
|
||||
Err(e) => error!("Failed to spawn portal: {e:#}"),
|
||||
}
|
||||
} else if let Some(path) = cmd.strip_prefix("file://") {
|
||||
let _ = std::process::Command::new(path).spawn();
|
||||
} else if cmd.contains(' ') {
|
||||
} else if crate::command_uses_shell(cmd) {
|
||||
let _ = std::process::Command::new("sh").arg("-c").arg(cmd).spawn();
|
||||
} else {
|
||||
let _ = std::process::Command::new(cmd).spawn();
|
||||
|
|
@ -1241,6 +1454,23 @@ fn process_electron_message(
|
|||
);
|
||||
state.metadata_dirty = true;
|
||||
}
|
||||
ElectronMessage::SiteHashes {
|
||||
window_name,
|
||||
update,
|
||||
} => {
|
||||
use sha2::Digest;
|
||||
if !update.index_bytes.is_empty() {
|
||||
let hashes = crate::electron::SiteHashes {
|
||||
page_url: update.page_url,
|
||||
index_sha256: format!("{:x}", sha2::Sha256::digest(&update.index_bytes)),
|
||||
styles_sha256: format!("{:x}", sha2::Sha256::digest(&update.styles_bytes)),
|
||||
};
|
||||
info!(window = %window_name, page = %hashes.page_url, "computed and registered document and stylesheet hashes");
|
||||
state.site_hashes.insert(window_name, hashes);
|
||||
} else {
|
||||
warn!(window = %window_name, "rejected empty site assets");
|
||||
}
|
||||
}
|
||||
ElectronMessage::Syscall(req) => {
|
||||
info!(window = %req.window_name, payload = %req.payload, "syscall received");
|
||||
if let Some(ref handler) = state.syscall_handler {
|
||||
|
|
@ -1310,7 +1540,8 @@ fn process_electron_message(
|
|||
.unwrap_or(0) as i32;
|
||||
// comraw reports the MacWindow origin (title bar top);
|
||||
// the Wayland surface sits th pixels below that.
|
||||
let prev_loc = state.space.element_location(&window).unwrap_or_default();
|
||||
let prev_loc =
|
||||
state.space.element_location(&window).unwrap_or_default();
|
||||
state.space.map_element(window.clone(), (x, y + th), false);
|
||||
state.translate_dialog_children(
|
||||
&window,
|
||||
|
|
@ -1436,11 +1667,20 @@ fn process_electron_message(
|
|||
smithay::utils::user_data::UserDataMap::new(),
|
||||
),
|
||||
};
|
||||
state.request_activation(token, token_data, surface.clone());
|
||||
state.request_activation(
|
||||
token,
|
||||
token_data,
|
||||
surface.clone(),
|
||||
);
|
||||
|
||||
if let Some(keyboard) = state.seat.get_keyboard() {
|
||||
let serial = smithay::utils::SERIAL_COUNTER.next_serial();
|
||||
keyboard.set_focus(state, Some(surface.clone()), serial);
|
||||
let serial =
|
||||
smithay::utils::SERIAL_COUNTER.next_serial();
|
||||
keyboard.set_focus(
|
||||
state,
|
||||
Some(surface.clone()),
|
||||
serial,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(name) = target_name.as_ref() {
|
||||
|
|
@ -1489,25 +1729,27 @@ fn process_electron_message(
|
|||
if window.wl_surface().is_none() {
|
||||
// Surface not ready yet.
|
||||
} else if let Some(loc) = state.space.element_location(&window) {
|
||||
let px = payload_json
|
||||
let mut px = payload_json
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("x"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let py = payload_json
|
||||
let mut py = payload_json
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("y"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let geometry_offset = window.geometry().loc;
|
||||
px += geometry_offset.x as f64;
|
||||
py += geometry_offset.y as f64;
|
||||
|
||||
// Client dictates event dispatch; compositor
|
||||
// owns pointer_location (hardware). Compute
|
||||
// a synthetic location for Smithay without
|
||||
// overwriting pointer_location.
|
||||
let dispatch_location: Point<f64, Logical> = (
|
||||
loc.x as f64 + px,
|
||||
loc.y as f64 + py,
|
||||
).into();
|
||||
let dispatch_location: Point<f64, Logical> =
|
||||
(loc.x as f64 + px, loc.y as f64 + py).into();
|
||||
|
||||
let under = window.surface_under(
|
||||
(px, py),
|
||||
|
|
@ -1552,22 +1794,24 @@ fn process_electron_message(
|
|||
.and_then(|p| p.get("pressed"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let px = payload_json
|
||||
let mut px = payload_json
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("x"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let py = payload_json
|
||||
let mut py = payload_json
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("y"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let geometry_offset = window.geometry().loc;
|
||||
px += geometry_offset.x as f64;
|
||||
py += geometry_offset.y as f64;
|
||||
|
||||
// Update pointer_location consistently
|
||||
state.pointer_location = (
|
||||
loc.x as f64 + px,
|
||||
loc.y as f64 + py,
|
||||
).into();
|
||||
state.pointer_location =
|
||||
(loc.x as f64 + px, loc.y as f64 + py).into();
|
||||
|
||||
// Send motion first so focus is correct for the button
|
||||
let under = window.surface_under(
|
||||
|
|
@ -1578,7 +1822,8 @@ fn process_electron_message(
|
|||
let focus_pos: Point<f64, Logical> = (
|
||||
loc.x as f64 + off.x as f64,
|
||||
loc.y as f64 + off.y as f64,
|
||||
).into();
|
||||
)
|
||||
.into();
|
||||
(s, focus_pos)
|
||||
});
|
||||
let time = event_start_time.elapsed().as_millis() as u32;
|
||||
|
|
@ -1754,6 +1999,7 @@ fn process_electron_message(
|
|||
proc.kill();
|
||||
}
|
||||
state.texture_cache.remove(&action_msg.window_name);
|
||||
state.current_frame_seq.remove(&action_msg.window_name);
|
||||
state.window_positions.remove(&action_msg.window_name);
|
||||
state.window_mapped.remove(&action_msg.window_name);
|
||||
if state.keyboard_window_focus.as_deref()
|
||||
|
|
@ -1804,10 +2050,16 @@ fn process_electron_message(
|
|||
}
|
||||
ElectronMessage::ForwardedPointerScroll {
|
||||
window_name,
|
||||
x,
|
||||
y,
|
||||
dx,
|
||||
dy,
|
||||
time,
|
||||
} => {
|
||||
state.handle_forwarded_pointer_scroll(&window_name, dx, dy);
|
||||
state.handle_forwarded_pointer_scroll(&window_name, x, y, dx, dy, time);
|
||||
}
|
||||
ElectronMessage::ForwardedPointerLeave { window_name } => {
|
||||
state.handle_forwarded_pointer_leave(&window_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1836,11 +2088,14 @@ fn send_window_metadata(state: &WoState) {
|
|||
"y": y,
|
||||
"width": w,
|
||||
"height": h,
|
||||
"content_width": w,
|
||||
"content_height": h,
|
||||
"z_order": win_cfg.z_order,
|
||||
"focused": is_focused,
|
||||
"mapped": mapped,
|
||||
"source": "electron",
|
||||
"ssd": true,
|
||||
"fullscreen": state.fullscreen_windows.contains(&win_cfg.name),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -1887,20 +2142,28 @@ fn send_window_metadata(state: &WoState) {
|
|||
let bbox_w = geo.size.w + geo.loc.x.max(0) * 2;
|
||||
let bbox_h = geo.size.h + geo.loc.y.max(0) * 2;
|
||||
|
||||
let th = state.title_h_for_window(window);
|
||||
let fullscreen = state.fullscreen_windows.contains(&window_name);
|
||||
let th = if fullscreen {
|
||||
0
|
||||
} else {
|
||||
state.title_h_for_window(window)
|
||||
};
|
||||
let is_ssd = th > 0;
|
||||
|
||||
let (parent_surface, parent_x11_id) = if let Some(x11) = window.x11_surface() {
|
||||
(None, x11.is_transient_for())
|
||||
} else {
|
||||
(window.toplevel().and_then(|t| {
|
||||
(
|
||||
window.toplevel().and_then(|t| {
|
||||
smithay::wayland::compositor::with_states(t.wl_surface(), |states| {
|
||||
states
|
||||
.data_map
|
||||
.get::<smithay::wayland::shell::xdg::XdgToplevelSurfaceData>()
|
||||
.and_then(|d| d.lock().ok().and_then(|data| data.parent.clone()))
|
||||
})
|
||||
}), None)
|
||||
}),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
let parent_name = if let Some(parent_wl) = parent_surface.as_ref() {
|
||||
|
|
@ -1941,11 +2204,14 @@ fn send_window_metadata(state: &WoState) {
|
|||
"y": loc.y - th - geo.loc.y.max(0),
|
||||
"width": bbox_w,
|
||||
"height": bbox_h + th,
|
||||
"content_width": geo.size.w,
|
||||
"content_height": geo.size.h,
|
||||
"z_order": space_idx,
|
||||
"focused": is_focused,
|
||||
"mapped": mapped,
|
||||
"source": if window.x11_surface().is_some() { "x11" } else { "wayland" },
|
||||
"ssd": is_ssd,
|
||||
"fullscreen": fullscreen,
|
||||
"dialog": is_dialog,
|
||||
"parent_name": parent_name,
|
||||
}));
|
||||
|
|
|
|||
260
src/state.rs
260
src/state.rs
|
|
@ -8,7 +8,7 @@ use smithay::{
|
|||
},
|
||||
utils::{Logical, Point, SERIAL_COUNTER},
|
||||
wayland::{
|
||||
compositor::{CompositorClientState, CompositorState, with_states}, cursor_shape::CursorShapeManagerState, dmabuf::{DmabufFeedback, DmabufFeedbackBuilder, DmabufState}, fractional_scale::{
|
||||
compositor::{CompositorClientState, CompositorState, with_states}, cursor_shape::CursorShapeManagerState, dmabuf::{DmabufFeedback, DmabufFeedbackBuilder, DmabufGlobal, DmabufState}, fractional_scale::{
|
||||
self as fractional_scale_mod,
|
||||
FractionalScaleHandler, FractionalScaleManagerState,
|
||||
}, output::OutputManagerState, selection::{
|
||||
|
|
@ -87,7 +87,11 @@ impl MappedShmSlot {
|
|||
anyhow::bail!("mmap failed for MappedShmSlot");
|
||||
}
|
||||
|
||||
Ok(Self { fd, ptr: ptr as *mut u8, len: size })
|
||||
Ok(Self {
|
||||
fd,
|
||||
ptr: ptr as *mut u8,
|
||||
len: size,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resize the slot, remapping if the new size differs.
|
||||
|
|
@ -200,6 +204,72 @@ pub struct BackendData {
|
|||
pub dmabuf_formats: Option<Vec<drm_fourcc::DrmFormat>>,
|
||||
}
|
||||
|
||||
/// One compositor output in the global desktop coordinate space.
|
||||
///
|
||||
/// DRM and nested backends can publish multiple entries. The primary output
|
||||
/// remains available through `WoState::output` for code paths that have not
|
||||
/// yet been made output-aware.
|
||||
#[derive(Clone)]
|
||||
pub struct MonitorOutput {
|
||||
pub id: String,
|
||||
pub output: Output,
|
||||
pub location: Point<i32, Logical>,
|
||||
pub size: (u32, u32),
|
||||
pub primary: bool,
|
||||
}
|
||||
|
||||
impl MonitorOutput {
|
||||
pub fn contains(&self, point: Point<f64, Logical>) -> bool {
|
||||
let x = point.x - self.location.x as f64;
|
||||
let y = point.y - self.location.y as f64;
|
||||
x >= 0.0 && y >= 0.0 && x < self.size.0 as f64 && y < self.size.1 as f64
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_point_to_monitors(
|
||||
monitors: &[MonitorOutput],
|
||||
point: Point<f64, Logical>,
|
||||
) -> Point<f64, Logical> {
|
||||
if monitors.is_empty() || monitors.iter().any(|monitor| monitor.contains(point)) {
|
||||
return point;
|
||||
}
|
||||
monitors
|
||||
.iter()
|
||||
.map(|monitor| {
|
||||
let max_x = monitor.location.x as f64 + monitor.size.0.saturating_sub(1) as f64;
|
||||
let max_y = monitor.location.y as f64 + monitor.size.1.saturating_sub(1) as f64;
|
||||
let x = point.x.clamp(monitor.location.x as f64, max_x);
|
||||
let y = point.y.clamp(monitor.location.y as f64, max_y);
|
||||
let dx = point.x - x;
|
||||
let dy = point.y - y;
|
||||
(x, y, dx * dx + dy * dy)
|
||||
})
|
||||
.min_by(|a, b| a.2.total_cmp(&b.2))
|
||||
.map(|(x, y, _)| (x, y).into())
|
||||
.unwrap_or(point)
|
||||
}
|
||||
|
||||
fn monitor_desktop_bounds(monitors: &[MonitorOutput]) -> smithay::utils::Rectangle<i32, Logical> {
|
||||
let Some(first) = monitors.first() else {
|
||||
return smithay::utils::Rectangle::new((0, 0).into(), (0, 0).into());
|
||||
};
|
||||
let extent = |pixels: u32| pixels.min(i32::MAX as u32) as i32;
|
||||
let mut left = first.location.x;
|
||||
let mut top = first.location.y;
|
||||
let mut right = left.saturating_add(extent(first.size.0));
|
||||
let mut bottom = top.saturating_add(extent(first.size.1));
|
||||
for monitor in monitors.iter().skip(1) {
|
||||
left = left.min(monitor.location.x);
|
||||
top = top.min(monitor.location.y);
|
||||
right = right.max(monitor.location.x.saturating_add(extent(monitor.size.0)));
|
||||
bottom = bottom.max(monitor.location.y.saturating_add(extent(monitor.size.1)));
|
||||
}
|
||||
smithay::utils::Rectangle::new(
|
||||
(left, top).into(),
|
||||
(right.saturating_sub(left), bottom.saturating_sub(top)).into(),
|
||||
)
|
||||
}
|
||||
|
||||
impl BackendData {
|
||||
pub fn renderer(&self) -> Option<std::sync::Arc<std::sync::Mutex<GlesRenderer>>> {
|
||||
self.renderer.clone()
|
||||
|
|
@ -213,7 +283,7 @@ pub struct WoState {
|
|||
pub xdg_decoration_state: XdgDecorationState,
|
||||
pub shm_state: ShmState,
|
||||
pub dmabuf_state: DmabufState,
|
||||
// pub dmabuf_global: DmabufGlobal,
|
||||
pub dmabuf_global: DmabufGlobal,
|
||||
pub dmabuf_default_feedback: DmabufFeedback,
|
||||
pub dmabuf_scanout_feedback: DmabufFeedback,
|
||||
pub seat_state: SeatState<Self>,
|
||||
|
|
@ -238,10 +308,19 @@ pub struct WoState {
|
|||
|
||||
pub space: Space<Window>,
|
||||
pub output: Output,
|
||||
/// Outputs ordered with the primary monitor first, in global desktop coordinates.
|
||||
pub monitors: Vec<MonitorOutput>,
|
||||
|
||||
pub electron_processes: Vec<ElectronProcess>,
|
||||
pub texture_cache: TextureCache,
|
||||
/// Sequence of the Electron DMA-BUF currently retained for each window.
|
||||
/// It is acknowledged only after a newer frame replaces it.
|
||||
pub current_frame_seq: std::collections::HashMap<String, u64>,
|
||||
pub window_positions: std::collections::HashMap<String, (i32, i32, u32, u32)>,
|
||||
/// Hashes of the verified document and theme stylesheet bound to each Electron window.
|
||||
pub site_hashes: std::collections::HashMap<String, crate::electron::SiteHashes>,
|
||||
pub fullscreen_restore: std::collections::HashMap<String, (i32, i32, u32, u32)>,
|
||||
pub fullscreen_windows: std::collections::HashSet<String>,
|
||||
pub electron_ipc: Option<ElectronIpc>,
|
||||
pub syscall_handler: Option<SyscallHandler>,
|
||||
pub portal: Option<std::sync::Arc<WoPortal>>,
|
||||
|
|
@ -350,7 +429,7 @@ impl WoState {
|
|||
let screencopy_state = Some(ScreencopyManagerState::new::<Self>(&dh));
|
||||
|
||||
let data_device_state = DataDeviceState::new::<Self>(&dh);
|
||||
let dmabuf_state = DmabufState::new();
|
||||
let mut dmabuf_state = DmabufState::new();
|
||||
|
||||
// Get DMABUF formats from renderer if available
|
||||
let (default_feedback, scanout_feedback) = if let Some(renderer_arc) = &backend.renderer {
|
||||
|
|
@ -394,13 +473,8 @@ impl WoState {
|
|||
.expect("building empty scanout dmabuf feedback");
|
||||
(default_fb, scanout_fb)
|
||||
};
|
||||
// Disable DMABUF advertising to force clients to use SHM for the pixel export pipeline.
|
||||
/*
|
||||
let dmabuf_global = dmabuf_state.create_global_with_default_feedback::<Self>(
|
||||
&dh,
|
||||
&default_feedback,
|
||||
);
|
||||
*/
|
||||
let dmabuf_global =
|
||||
dmabuf_state.create_global_with_default_feedback::<Self>(&dh, &default_feedback);
|
||||
let popup_manager = PopupManager::default();
|
||||
let space = Space::default();
|
||||
|
||||
|
|
@ -416,7 +490,7 @@ impl WoState {
|
|||
xdg_decoration_state,
|
||||
shm_state,
|
||||
dmabuf_state,
|
||||
// dmabuf_global,
|
||||
dmabuf_global,
|
||||
dmabuf_default_feedback: default_feedback,
|
||||
dmabuf_scanout_feedback: scanout_feedback,
|
||||
seat_state,
|
||||
|
|
@ -438,10 +512,21 @@ impl WoState {
|
|||
keyboard_window_focus: None,
|
||||
output_size,
|
||||
space,
|
||||
output: output.clone(),
|
||||
monitors: vec![MonitorOutput {
|
||||
id: "wo-output".to_string(),
|
||||
output,
|
||||
location: (0, 0).into(),
|
||||
size: output_size,
|
||||
primary: true,
|
||||
}],
|
||||
electron_processes: vec![],
|
||||
texture_cache: TextureCache::default(),
|
||||
current_frame_seq: std::collections::HashMap::new(),
|
||||
window_positions: std::collections::HashMap::new(),
|
||||
site_hashes: std::collections::HashMap::new(),
|
||||
fullscreen_restore: std::collections::HashMap::new(),
|
||||
fullscreen_windows: std::collections::HashSet::new(),
|
||||
electron_ipc: None,
|
||||
syscall_handler,
|
||||
portal,
|
||||
|
|
@ -473,14 +558,33 @@ impl WoState {
|
|||
// Map the output into the space at origin.
|
||||
state.space.map_output(&state.output, (0, 0));
|
||||
|
||||
// Initialize mapping state for configured windows as mapped (visible).
|
||||
// Initialize configured windows and desktop roots as mapped (visible).
|
||||
for win in &state.config.windows {
|
||||
state.window_mapped.insert(win.name.clone(), true);
|
||||
}
|
||||
for root in &state.config.root {
|
||||
state.window_mapped.insert(root.name.clone(), true);
|
||||
}
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
/// Find the output under a global logical desktop coordinate.
|
||||
pub fn monitor_at(&self, point: Point<f64, Logical>) -> Option<&MonitorOutput> {
|
||||
self.monitors.iter().find(|monitor| monitor.contains(point))
|
||||
}
|
||||
|
||||
/// Clamp a global pointer position to a real output, including layouts
|
||||
/// with negative origins or gaps between monitors.
|
||||
pub fn clamp_pointer_to_monitors(&self, point: Point<f64, Logical>) -> Point<f64, Logical> {
|
||||
clamp_point_to_monitors(&self.monitors, point)
|
||||
}
|
||||
|
||||
/// Return the union of all output rectangles in global logical coordinates.
|
||||
pub fn desktop_bounds(&self) -> smithay::utils::Rectangle<i32, Logical> {
|
||||
monitor_desktop_bounds(&self.monitors)
|
||||
}
|
||||
|
||||
/// Return the effective title-bar height for a Wayland window.
|
||||
/// SSD windows get TITLE_H (comraw draws the title bar); CSD windows get 0
|
||||
/// (the client draws its own decorations).
|
||||
|
|
@ -619,19 +723,28 @@ impl WoState {
|
|||
|
||||
/// Find a Wayland or X11 Window element by its stable name (even if minimized).
|
||||
pub fn find_wayland_window(&self, name: &str) -> Option<Window> {
|
||||
self.all_windows.iter().find(|w| {
|
||||
self.all_windows
|
||||
.iter()
|
||||
.find(|w| {
|
||||
if let Some(toplevel) = w.toplevel() {
|
||||
if let Some(win_name) = self.wayland_window_names.get(&toplevel.wl_surface().id()) {
|
||||
if win_name == name { return true; }
|
||||
if let Some(win_name) =
|
||||
self.wayland_window_names.get(&toplevel.wl_surface().id())
|
||||
{
|
||||
if win_name == name {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(x11) = w.x11_surface() {
|
||||
if let Some(win_name) = self.x11_window_names.get(&x11.window_id()) {
|
||||
if win_name == name { return true; }
|
||||
if win_name == name {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}).cloned()
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Focus the topmost mapped native window in `space`.
|
||||
|
|
@ -703,7 +816,8 @@ impl WoState {
|
|||
// Translate global space coordinate to surface-local coordinate
|
||||
// window.geometry().loc is the offset from surface origin to content area.
|
||||
let geo = window.geometry();
|
||||
let surface_local: Point<f64, Logical> = (local.x + geo.loc.x as f64, local.y + geo.loc.y as f64).into();
|
||||
let surface_local: Point<f64, Logical> =
|
||||
(local.x + geo.loc.x as f64, local.y + geo.loc.y as f64).into();
|
||||
window
|
||||
.surface_under(surface_local, smithay::desktop::WindowSurfaceType::ALL)
|
||||
.map(|(s, p)| (s, p.to_f64() + window_pos.to_f64()))
|
||||
|
|
@ -711,6 +825,70 @@ impl WoState {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod monitor_layout_tests {
|
||||
use super::*;
|
||||
use smithay::output::{PhysicalProperties, Subpixel};
|
||||
|
||||
fn monitor(id: &str, x: i32, y: i32, width: u32, height: u32) -> MonitorOutput {
|
||||
MonitorOutput {
|
||||
id: id.to_string(),
|
||||
output: Output::new(
|
||||
id.to_string(),
|
||||
PhysicalProperties {
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: id.into(),
|
||||
},
|
||||
),
|
||||
location: (x, y).into(),
|
||||
size: (width, height),
|
||||
primary: x == 0 && y == 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_hit_test_uses_global_coordinates_and_excludes_far_edge() {
|
||||
let displays = vec![monitor("left", -100, 20, 100, 80)];
|
||||
assert!(displays[0].contains((-100.0, 20.0).into()));
|
||||
assert!(displays[0].contains((-0.5, 99.5).into()));
|
||||
assert!(!displays[0].contains((0.0, 20.0).into()));
|
||||
assert!(!displays[0].contains((-1.0, 100.0).into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_clamps_to_nearest_output_across_layout_gaps() {
|
||||
let displays = vec![
|
||||
monitor("left", -100, 0, 100, 100),
|
||||
monitor("right", 20, 0, 100, 100),
|
||||
];
|
||||
assert_eq!(
|
||||
clamp_point_to_monitors(&displays, (-110.0, 40.0).into()),
|
||||
(-100.0, 40.0).into()
|
||||
);
|
||||
assert_eq!(
|
||||
clamp_point_to_monitors(&displays, (0.0, 40.0).into()),
|
||||
(-1.0, 40.0).into()
|
||||
);
|
||||
assert_eq!(
|
||||
clamp_point_to_monitors(&displays, (300.0, 120.0).into()),
|
||||
(119.0, 99.0).into()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_bounds_include_negative_origins_and_output_gaps() {
|
||||
let displays = vec![
|
||||
monitor("left", -100, -20, 100, 80),
|
||||
monitor("right", 20, 10, 120, 90),
|
||||
];
|
||||
let bounds = monitor_desktop_bounds(&displays);
|
||||
assert_eq!(bounds.loc, (-100, -20).into());
|
||||
assert_eq!(bounds.size, (240, 120).into());
|
||||
}
|
||||
}
|
||||
|
||||
impl SeatHandler for WoState {
|
||||
type KeyboardFocus = smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
type PointerFocus = smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
|
|
@ -800,14 +978,34 @@ smithay::delegate_cursor_shape!(WoState);
|
|||
impl PointerConstraintsHandler for WoState {
|
||||
fn cursor_position_hint(
|
||||
&mut self,
|
||||
_surface: &WlSurface,
|
||||
_pointer: &PointerHandle<Self>,
|
||||
_location: Point<f64, Logical>,
|
||||
surface: &WlSurface,
|
||||
pointer: &PointerHandle<Self>,
|
||||
location: Point<f64, Logical>,
|
||||
) {
|
||||
let window = self.space.elements().find(|window| {
|
||||
window
|
||||
.toplevel()
|
||||
.map(|toplevel| toplevel.wl_surface() == surface)
|
||||
.unwrap_or(false)
|
||||
|| window
|
||||
.x11_surface()
|
||||
.and_then(|x11| x11.wl_surface())
|
||||
.map(|wl_surface| wl_surface == *surface)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
if let Some(window) = window {
|
||||
if let Some(origin) = self.space.element_location(window) {
|
||||
let content_origin = origin + window.geometry().loc;
|
||||
let hinted_location = content_origin.to_f64() + location;
|
||||
self.pointer_location = hinted_location;
|
||||
pointer.set_location(hinted_location);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_constraint(&mut self, surface: &WlSurface, pointer: &PointerHandle<Self>) {
|
||||
// When a new constraint is created, check if it's a lock and notify Electron.
|
||||
// Notify Electron when a native client requests a pointer lock.
|
||||
pointer_constraints::with_pointer_constraint(surface, pointer, |constraint| {
|
||||
if let Some(constraint) = constraint {
|
||||
if let smithay::wayland::pointer_constraints::PointerConstraint::Locked(_) =
|
||||
|
|
@ -868,9 +1066,7 @@ pub fn propagate_environment(vars: &[&str]) {
|
|||
|
||||
let var_args: Vec<String> = vars
|
||||
.iter()
|
||||
.filter_map(|&name| {
|
||||
std::env::var(name).ok().map(|val| format!("{name}={val}"))
|
||||
})
|
||||
.filter_map(|&name| std::env::var(name).ok().map(|val| format!("{name}={val}")))
|
||||
.collect();
|
||||
|
||||
if var_args.is_empty() {
|
||||
|
|
@ -889,7 +1085,11 @@ pub fn propagate_environment(vars: &[&str]) {
|
|||
for arg in &var_args {
|
||||
dbus_cmd.arg(arg);
|
||||
}
|
||||
match dbus_cmd.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() {
|
||||
match dbus_cmd
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
Ok(mut child) => {
|
||||
let _ = child.wait();
|
||||
info!("Propagated {:?} to DBus activation environment", names);
|
||||
|
|
@ -903,7 +1103,11 @@ pub fn propagate_environment(vars: &[&str]) {
|
|||
for name in &names {
|
||||
systemctl_cmd.arg(name);
|
||||
}
|
||||
match systemctl_cmd.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() {
|
||||
match systemctl_cmd
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
Ok(mut child) => {
|
||||
let _ = child.wait();
|
||||
info!("Propagated {:?} to systemd user environment", names);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
/// xdg-desktop-portal implementation for Wo compositor
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use pipewire as pw;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -55,7 +54,6 @@ pub struct WoPortal {
|
|||
sessions: Arc<Mutex<HashMap<String, PortalSession>>>,
|
||||
next_session_id: Arc<Mutex<u64>>,
|
||||
streams: Arc<Mutex<HashMap<u32, Arc<ScreenCaptureStream>>>>,
|
||||
compositor_socket: Option<String>,
|
||||
}
|
||||
|
||||
impl WoPortal {
|
||||
|
|
@ -64,38 +62,9 @@ impl WoPortal {
|
|||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
next_session_id: Arc::new(Mutex::new(1)),
|
||||
streams: Arc::new(Mutex::new(HashMap::new())),
|
||||
compositor_socket: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_compositor_socket(mut self, socket: String) -> Self {
|
||||
self.compositor_socket = Some(socket);
|
||||
self
|
||||
}
|
||||
|
||||
/// Request capture from compositor via IPC
|
||||
fn request_compositor_capture(&self, window: &Option<String>, source_type: SourceType) -> Result<()> {
|
||||
if let Some(socket_path) = &self.compositor_socket {
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::io::Write;
|
||||
|
||||
// Connect to compositor IPC
|
||||
if let Ok(mut stream) = UnixStream::connect(socket_path) {
|
||||
let request = serde_json::json!({
|
||||
"type": "portal_capture_request",
|
||||
"source_type": source_type,
|
||||
"window": window,
|
||||
});
|
||||
let msg = serde_json::to_string(&request)?;
|
||||
let _ = stream.write_all(msg.as_bytes());
|
||||
info!("Requested capture from compositor: {:?}", source_type);
|
||||
} else {
|
||||
warn!("Could not connect to compositor socket: {}", socket_path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new portal session
|
||||
pub fn create_session(&self) -> String {
|
||||
let mut next_id = self.next_session_id.lock().unwrap();
|
||||
|
|
@ -109,21 +78,18 @@ impl WoPortal {
|
|||
active: false,
|
||||
};
|
||||
|
||||
self.sessions.lock().unwrap().insert(session_id.clone(), session);
|
||||
self.sessions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(session_id.clone(), session);
|
||||
info!("Created portal session: {}", session_id);
|
||||
session_id
|
||||
}
|
||||
|
||||
/// Select sources for a session
|
||||
pub fn select_sources(
|
||||
&self,
|
||||
session_id: &str,
|
||||
request: CaptureRequest,
|
||||
) -> Result<()> {
|
||||
pub fn select_sources(&self, session_id: &str, request: CaptureRequest) -> Result<()> {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
let session = sessions
|
||||
.get_mut(session_id)
|
||||
.context("session not found")?;
|
||||
let session = sessions.get_mut(session_id).context("session not found")?;
|
||||
|
||||
session.source_type = request.source_type;
|
||||
session.target = request.target.clone();
|
||||
|
|
@ -143,9 +109,7 @@ impl WoPortal {
|
|||
height: u32,
|
||||
) -> Result<CaptureResponse> {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
let session = sessions
|
||||
.get_mut(session_id)
|
||||
.context("session not found")?;
|
||||
let session = sessions.get_mut(session_id).context("session not found")?;
|
||||
|
||||
session.active = true;
|
||||
|
||||
|
|
@ -155,7 +119,8 @@ impl WoPortal {
|
|||
.filter(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u32>()
|
||||
.unwrap_or(1) + 100; // Offset to avoid conflicts
|
||||
.unwrap_or(1)
|
||||
+ 100; // Offset to avoid conflicts
|
||||
|
||||
// Create the actual PipeWire stream
|
||||
let stream = Arc::new(ScreenCaptureStream::new(
|
||||
|
|
@ -172,9 +137,6 @@ impl WoPortal {
|
|||
let mut streams = self.streams.lock().unwrap();
|
||||
streams.insert(real_node_id, stream);
|
||||
|
||||
// Request capture from compositor
|
||||
self.request_compositor_capture(&session.target, session.source_type)?;
|
||||
|
||||
let stream_info = StreamInfo {
|
||||
node_id: real_node_id,
|
||||
width,
|
||||
|
|
@ -262,11 +224,7 @@ impl WoPortal {
|
|||
|
||||
/// Push raw pixel data (ARGB8888/BGRx) to matching portal streams.
|
||||
/// Called from the compositor after offscreen capture or full-screen compositing.
|
||||
pub fn push_pixels_to_streams(
|
||||
&self,
|
||||
window_name: Option<&str>,
|
||||
pixels: &[u8],
|
||||
) {
|
||||
pub fn push_pixels_to_streams(&self, window_name: Option<&str>, pixels: &[u8]) {
|
||||
let streams = self.streams.lock().unwrap();
|
||||
for stream in streams.values() {
|
||||
let matches = match (&stream.window_name, window_name) {
|
||||
|
|
@ -280,6 +238,47 @@ impl WoPortal {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish a window crop from the already-composited desktop frame.
|
||||
/// The output is scaled to the negotiated PipeWire size so captures remain
|
||||
/// valid when a window changes size after portal Start.
|
||||
pub fn push_window_capture(
|
||||
&self,
|
||||
window_name: &str,
|
||||
pixels: &[u8],
|
||||
screen_width: u32,
|
||||
screen_height: u32,
|
||||
bounds: (i32, i32, u32, u32),
|
||||
) {
|
||||
let streams = self.streams.lock().unwrap();
|
||||
let (x, y, width, height) = bounds;
|
||||
if screen_width == 0 || screen_height == 0 || width == 0 || height == 0 {
|
||||
return;
|
||||
}
|
||||
for stream in streams.values().filter(|stream| {
|
||||
stream.window_name.as_deref() == Some(window_name) && stream.is_active()
|
||||
}) {
|
||||
let mut output = vec![0; stream.width as usize * stream.height as usize * 4];
|
||||
for dy in 0..stream.height {
|
||||
let sy = y + (dy as u64 * height as u64 / stream.height.max(1) as u64) as i32;
|
||||
if sy < 0 || sy >= screen_height as i32 {
|
||||
continue;
|
||||
}
|
||||
for dx in 0..stream.width {
|
||||
let sx = x + (dx as u64 * width as u64 / stream.width.max(1) as u64) as i32;
|
||||
if sx < 0 || sx >= screen_width as i32 {
|
||||
continue;
|
||||
}
|
||||
let src = (sy as usize * screen_width as usize + sx as usize) * 4;
|
||||
let dst = (dy as usize * stream.width as usize + dx as usize) * 4;
|
||||
if src + 4 <= pixels.len() {
|
||||
output[dst..dst + 4].copy_from_slice(&pixels[src..src + 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
stream.push_pixels(&output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WoPortal {
|
||||
|
|
@ -316,7 +315,10 @@ impl PortalDBusHandler {
|
|||
}
|
||||
("org.freedesktop.portal.ScreenCast", "SelectSources") => {
|
||||
let request: CaptureRequest = serde_json::from_value(args)?;
|
||||
let session_id = request.restore_token.clone().unwrap_or_else(|| "default".to_string());
|
||||
let session_id = request
|
||||
.restore_token
|
||||
.clone()
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
self.portal.select_sources(&session_id, request)?;
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
}
|
||||
|
|
@ -387,7 +389,13 @@ impl ScreenCaptureStream {
|
|||
}
|
||||
|
||||
/// Push a frame to the PipeWire stream with DMABUF
|
||||
pub fn push_frame(&self, dmabuf_fd: i32, stride: u32, offset: u32, modifier: u64) -> Result<()> {
|
||||
pub fn push_frame(
|
||||
&self,
|
||||
dmabuf_fd: i32,
|
||||
stride: u32,
|
||||
offset: u32,
|
||||
modifier: u64,
|
||||
) -> Result<()> {
|
||||
if !*self.active.lock().unwrap() {
|
||||
return Ok(()); // Stream not active
|
||||
}
|
||||
|
|
@ -411,7 +419,8 @@ impl ScreenCaptureStream {
|
|||
queue.push(frame.clone());
|
||||
|
||||
// Keep queue bounded to prevent memory issues
|
||||
if queue.len() > 30 { // 30 frame buffer
|
||||
if queue.len() > 30 {
|
||||
// 30 frame buffer
|
||||
queue.remove(0);
|
||||
}
|
||||
|
||||
|
|
@ -462,13 +471,9 @@ impl ScreenCaptureStream {
|
|||
std::thread::Builder::new()
|
||||
.name(format!("pw-capture-{}", self.node_id))
|
||||
.spawn(move || {
|
||||
if let Err(e) = run_pw_output_stream(
|
||||
width,
|
||||
height,
|
||||
active.clone(),
|
||||
latest_pixels,
|
||||
node_tx,
|
||||
) {
|
||||
if let Err(e) =
|
||||
run_pw_output_stream(width, height, active.clone(), latest_pixels, node_tx)
|
||||
{
|
||||
warn!("PipeWire stream thread failed: {e:#}");
|
||||
// Mark inactive on failure so the stream reports itself as dead.
|
||||
*active.lock().unwrap() = false;
|
||||
|
|
@ -516,7 +521,11 @@ impl ScreenCaptureStream {
|
|||
impl Drop for ScreenCaptureStream {
|
||||
fn drop(&mut self) {
|
||||
self.disconnect();
|
||||
debug!("Stream {}: dropped (pushed {} frames)", self.node_id, self.frame_count());
|
||||
debug!(
|
||||
"Stream {}: dropped (pushed {} frames)",
|
||||
self.node_id,
|
||||
self.frame_count()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -665,10 +674,7 @@ fn run_pw_output_stream(
|
|||
Fraction,
|
||||
pw::spa::utils::Fraction { num: 60, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 1, denom: 1 },
|
||||
pw::spa::utils::Fraction {
|
||||
num: 120,
|
||||
denom: 1
|
||||
}
|
||||
pw::spa::utils::Fraction { num: 120, denom: 1 }
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
/// Dynamic syscall handling for IPC
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
|
@ -49,19 +48,13 @@ pub enum SyscallRequest {
|
|||
},
|
||||
/// List directory contents
|
||||
#[serde(rename = "listdir")]
|
||||
ListDir {
|
||||
path: String,
|
||||
},
|
||||
ListDir { path: String },
|
||||
/// Get file/directory metadata
|
||||
#[serde(rename = "stat")]
|
||||
Stat {
|
||||
path: String,
|
||||
},
|
||||
Stat { path: String },
|
||||
/// Delete file
|
||||
#[serde(rename = "delete")]
|
||||
Delete {
|
||||
path: String,
|
||||
},
|
||||
Delete { path: String },
|
||||
/// Custom extension syscall
|
||||
#[serde(rename = "custom")]
|
||||
Custom {
|
||||
|
|
@ -99,12 +92,8 @@ pub trait ExtensionHandler: Send + Sync {
|
|||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "status")]
|
||||
pub enum SyscallResponse {
|
||||
Success {
|
||||
data: serde_json::Value,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
Success { data: serde_json::Value },
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// Syscall handler with security policies
|
||||
|
|
@ -176,10 +165,10 @@ impl SyscallHandler {
|
|||
SyscallRequest::ListApplications => {
|
||||
Ok(serde_json::Value::Array(self.applications.clone()))
|
||||
}
|
||||
SyscallRequest::BrowseApplications => {
|
||||
browse_applications()
|
||||
SyscallRequest::BrowseApplications => browse_applications(),
|
||||
SyscallRequest::Launch { command, args } => {
|
||||
self.handle_launch(&command, args.as_deref())
|
||||
}
|
||||
SyscallRequest::Launch { command, args } => self.handle_launch(&command, args.as_deref()),
|
||||
SyscallRequest::Exec {
|
||||
command,
|
||||
args,
|
||||
|
|
@ -198,12 +187,8 @@ impl SyscallHandler {
|
|||
SyscallRequest::Quit => {
|
||||
std::process::exit(0);
|
||||
}
|
||||
SyscallRequest::Shutdown => {
|
||||
handle_power_command("systemctl", &["poweroff"])
|
||||
}
|
||||
SyscallRequest::Restart => {
|
||||
handle_power_command("systemctl", &["reboot"])
|
||||
}
|
||||
SyscallRequest::Shutdown => handle_power_command("systemctl", &["poweroff"]),
|
||||
SyscallRequest::Restart => handle_power_command("systemctl", &["reboot"]),
|
||||
SyscallRequest::Logout => {
|
||||
if let Ok(session_id) = std::env::var("XDG_SESSION_ID") {
|
||||
handle_power_command("loginctl", &["terminate-session", &session_id])
|
||||
|
|
@ -212,25 +197,21 @@ impl SyscallHandler {
|
|||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
SyscallRequest::Lock => {
|
||||
handle_power_command("loginctl", &["lock-session"])
|
||||
}
|
||||
SyscallRequest::Sleep => {
|
||||
handle_power_command("systemctl", &["suspend"])
|
||||
}
|
||||
SyscallRequest::Lock => handle_power_command("loginctl", &["lock-session"]),
|
||||
SyscallRequest::Sleep => handle_power_command("systemctl", &["suspend"]),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_launch(
|
||||
&self,
|
||||
command: &str,
|
||||
args: Option<&[String]>,
|
||||
) -> Result<serde_json::Value> {
|
||||
fn handle_launch(&self, command: &str, args: Option<&[String]>) -> Result<serde_json::Value> {
|
||||
if !self.allow_exec {
|
||||
bail!("exec syscalls are disabled");
|
||||
}
|
||||
|
||||
tracing::info!("Launching command (fire-and-forget): {} {:?}", command, args);
|
||||
tracing::info!(
|
||||
"Launching command (fire-and-forget): {} {:?}",
|
||||
command,
|
||||
args
|
||||
);
|
||||
|
||||
let mut cmd = Command::new("sh");
|
||||
let shell_cmd = if let Some(args) = args {
|
||||
|
|
@ -302,12 +283,10 @@ impl SyscallHandler {
|
|||
.stderr(Stdio::null());
|
||||
|
||||
match cmd.spawn() {
|
||||
Ok(child) => {
|
||||
Ok(serde_json::json!({
|
||||
Ok(child) => Ok(serde_json::json!({
|
||||
"pid": child.id(),
|
||||
"launched": true,
|
||||
}))
|
||||
}
|
||||
})),
|
||||
Err(e) => {
|
||||
bail!("failed to execute '{}': {}", command, e);
|
||||
}
|
||||
|
|
@ -436,10 +415,7 @@ impl SyscallHandler {
|
|||
.context("HOME not set")?;
|
||||
let canonical = path.canonicalize().context("canonicalizing path")?;
|
||||
if !canonical.starts_with(&home) && !canonical.starts_with("/proc") {
|
||||
bail!(
|
||||
"path outside home directory: {}",
|
||||
canonical.display()
|
||||
);
|
||||
bail!("path outside home directory: {}", canonical.display());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -460,10 +436,7 @@ impl SyscallHandler {
|
|||
if parent.exists() {
|
||||
let canonical = parent.canonicalize().context("canonicalizing parent")?;
|
||||
if !canonical.starts_with(&home) {
|
||||
bail!(
|
||||
"write path outside home directory: {}",
|
||||
canonical.display()
|
||||
);
|
||||
bail!("write path outside home directory: {}", canonical.display());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -489,7 +462,9 @@ fn handle_power_command(program: &str, args: &[&str]) -> Result<serde_json::Valu
|
|||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
match cmd.spawn() {
|
||||
Ok(_child) => Ok(serde_json::json!({ "ok": true, "command": format!("{} {}", program, args.join(" ")) })),
|
||||
Ok(_child) => Ok(
|
||||
serde_json::json!({ "ok": true, "command": format!("{} {}", program, args.join(" ")) }),
|
||||
),
|
||||
Err(e) => bail!("failed to execute '{}': {}", program, e),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
360
whatever.txt
360
whatever.txt
|
|
@ -1,222 +1,154 @@
|
|||
Compiling wo v0.1.0 (/home/hellings/Documents/wo)
|
||||
Compiling wo v0.1.0 (/home/hellings/Documents/wo)
|
||||
Finished `release` profile [optimized] target(s) in 2m 21s
|
||||
Finished `release` profile [optimized] target(s) in 2m 21s
|
||||
Running `target/release/wo`
|
||||
Running `target/release/wo`
|
||||
Xwayland glamor: GBM Wayland interfaces not available
|
||||
Failed to initialize glamor, falling back to sw
|
||||
Xwayland glamor: GBM Wayland interfaces not available
|
||||
Failed to initialize glamor, falling back to sw
|
||||
(II) IGLX: Loaded and initialized swrast
|
||||
(II) GLX: Initialized DRISWRAST GL provider for screen 0
|
||||
(II) IGLX: Loaded and initialized swrast
|
||||
(II) GLX: Initialized DRISWRAST GL provider for screen 0
|
||||
[2m2026-10-26T03:36:18.813061Z[0m [32m INFO[0m [2mwo[0m[2m:[0m wo compositor starting
|
||||
[2m2026-10-26T03:36:18.813121Z[0m [32m INFO[0m [2mwo::config[0m[2m:[0m Loading config from "/home/hellings/.config/wo/config.toml"
|
||||
[2m2026-10-26T03:36:18.813191Z[0m [32m INFO[0m [2mwo[0m[2m:[0m Attempting nested mode (gles renderer)
|
||||
[2m2026-10-26T03:36:18.813218Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m Starting nested compositor mode
|
||||
[2m2026-10-26T03:36:18.813231Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m Press ESC to exit
|
||||
[2m2026-10-26T03:36:18.813449Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m Wayland socket created [3msocket[0m[2m=[0mwo-0
|
||||
[2m2026-10-26T03:36:18.813504Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m Using host Wayland display for nested startup [3msocket[0m[2m=[0mwayland-0
|
||||
[2m2026-10-26T03:36:18.856187Z[0m [31mERROR[0m [2msmithay::backend::egl::ffi[0m[2m:[0m [EGL] 0x3004 (BAD_ATTRIBUTE) eglCreateSyncKHR: EGL_BAD_ATTRIBUTE error: In eglCreateSyncKHR: EGL_SYNC_NATIVE_FENCE_FD_ANDROID specified valid fd butEGL_SYNC_STATUS is also being set
|
||||
|
||||
[2m2026-10-26T03:36:18.911103Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m Nested window size [3mwidth[0m[2m=[0m1280 [3mheight[0m[2m=[0m800
|
||||
[2m2026-10-26T03:36:18.911169Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m Using render node: /dev/dri/renderD128
|
||||
[2m2026-10-26T03:36:18.911238Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m Collected 672 dmabuf formats from renderer
|
||||
[2m2026-10-26T03:36:18.911502Z[0m [32m INFO[0m [2mwo[0m[2m:[0m Started configured service before root windows [3mname[0m[2m=[0mvite [3mpid[0m[2m=[0m1378486
|
||||
[2m2026-10-26T03:36:18.913053Z[0m [32m INFO[0m [2mwo::handlers::screencopy[0m[2m:[0m Registered zwlr_screencopy_manager_v1 global (v3)
|
||||
[2m2026-10-26T03:36:18.913289Z[0m [32m INFO[0m [2mwo::cursor[0m[2m:[0m Loading cursor theme 'Adwaita' (size 24)
|
||||
[2m2026-10-26T03:36:18.913344Z[0m [32m INFO[0m [2mwo::cursor[0m[2m:[0m Loading cursor theme 'default' (size 24)
|
||||
[2m2026-10-26T03:36:18.913378Z[0m [32m INFO[0m [2mwo::cursor[0m[2m:[0m Loading cursor theme 'DMZ-White' (size 24)
|
||||
[2m2026-10-26T03:36:18.913400Z[0m [32m INFO[0m [2mwo::cursor[0m[2m:[0m Loading cursor theme 'breeze' (size 24)
|
||||
[2m2026-10-26T03:36:18.913478Z[0m [32m INFO[0m [2mwo::electron[0m[2m:[0m IPC socket listening [3msocket[0m[2m=[0m"/run/user/1000/wo-ipc.sock"
|
||||
[2m2026-10-26T03:36:18.916655Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m XWayland server starting
|
||||
[2m2026-10-26T03:36:18.916688Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m Set WAYLAND_DISPLAY=wo-0
|
||||
[2m2026-10-26T03:36:18.919374Z[0m [32m INFO[0m [2mwo::state[0m[2m:[0m Propagated ["WAYLAND_DISPLAY"] to DBus activation environment
|
||||
[2m2026-10-26T03:36:18.922988Z[0m [32m INFO[0m [2mwo::state[0m[2m:[0m Propagated ["WAYLAND_DISPLAY"] to systemd user environment
|
||||
[2m2026-10-26T03:36:18.923014Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m Nested compositor running
|
||||
[2m2026-10-26T03:36:18.923039Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m Window resized to 1280x800
|
||||
[2m2026-10-26T03:36:18.923058Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m Window resized to 1280x800
|
||||
[2m2026-10-26T03:36:18.962626Z[0m [32m INFO[0m [2mwo::handlers::compositor[0m[2m:[0m Client missing ClientData (likely XWayland), using fallback compositor state
|
||||
[2m2026-10-26T03:36:19.023297Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m Spawned portal service: "/home/hellings/.local/share/wo/bin/wo-portal"
|
||||
[2m2026-10-26T03:36:19.025666Z[0m [32m INFO[0m [2mwo_portal[0m[2m:[0m xdg-desktop-portal-wo starting
|
||||
[2m2026-10-26T03:36:19.026881Z[0m [32m INFO[0m [2mwo_portal[0m[2m:[0m Portal service registered as org.freedesktop.impl.portal.desktop.wo
|
||||
[2m2026-10-26T03:36:19.026938Z[0m [32m INFO[0m [2mwo_portal[0m[2m:[0m Listening for screen capture requests...
|
||||
(II) GLX: Initialized glamor GL provider for screen 0
|
||||
The XKEYBOARD keymap compiler (xkbcomp) reports:
|
||||
> Warning: Multiple symbols for level 1/group 1 on key <FK23>
|
||||
> Using F23, ignoring XF86TouchpadOff
|
||||
> Warning: Symbol map for key <FK23> redefined
|
||||
> Using last definition for conflicting fields
|
||||
> Warning: Symbol map for key <FK24> redefined
|
||||
> Using last definition for conflicting fields
|
||||
The XKEYBOARD keymap compiler (xkbcomp) reports:
|
||||
> Warning: Multiple symbols for level 1/group 1 on key <FK23>
|
||||
> Using F23, ignoring XF86TouchpadOff
|
||||
> Warning: Symbol map for key <FK23> redefined
|
||||
> Using last definition for conflicting fields
|
||||
> Warning: Symbol map for key <FK24> redefined
|
||||
> Using last definition for conflicting fields
|
||||
Errors from xkbcomp are not fatal to the X server
|
||||
> Warning: Could not resolve keysym XF86CameraAccessEnable
|
||||
> Warning: Could not resolve keysym XF86CameraAccessDisable
|
||||
> Warning: Could not resolve keysym XF86CameraAccessToggle
|
||||
> Warning: Could not resolve keysym XF86NextElement
|
||||
> Warning: Could not resolve keysym XF86PreviousElement
|
||||
> Warning: Could not resolve keysym XF86AutopilotEngageToggle
|
||||
> Warning: Could not resolve keysym XF86MarkWaypoint
|
||||
> Warning: Could not resolve keysym XF86Sos
|
||||
> Warning: Could not resolve keysym XF86NavChart
|
||||
> Warning: Could not resolve keysym XF86FishingChart
|
||||
> Warning: Could not resolve keysym XF86SingleRangeRadar
|
||||
> Warning: Could not resolve keysym XF86DualRangeRadar
|
||||
> Warning: Could not resolve keysym XF86RadarOverlay
|
||||
> Warning: Could not resolve keysym XF86TraditionalSonar
|
||||
> Warning: Could not resolve keysym XF86ClearvuSonar
|
||||
> Warning: Could not resolve keysym XF86SidevuSonar
|
||||
> Warning: Could not resolve keysym XF86NavInfo
|
||||
Errors from xkbcomp are not fatal to the X server
|
||||
[2m2026-10-26T03:36:19.106508Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m XWayland ready on DISPLAY :1
|
||||
[2m2026-10-26T03:36:19.108904Z[0m [32m INFO[0m [2mwo::state[0m[2m:[0m Propagated ["DISPLAY", "WAYLAND_DISPLAY"] to DBus activation environment
|
||||
[2m2026-10-26T03:36:19.112684Z[0m [32m INFO[0m [2mwo::state[0m[2m:[0m Propagated ["DISPLAY", "WAYLAND_DISPLAY"] to systemd user environment
|
||||
The XKEYBOARD keymap compiler (xkbcomp) reports:
|
||||
> Warning: Unsupported maximum keycode 709, clipping.
|
||||
> Warning: Unsupported maximum keycode 708, clipping.
|
||||
> X11 cannot support keycodes above 255.
|
||||
The XKEYBOARD keymap compiler (xkbcomp) reports:
|
||||
> Warning: Unsupported maximum keycode 709, clipping.
|
||||
> X11 cannot support keycodes above 255.
|
||||
> Warning: Virtual modifier Hyper multiply defined
|
||||
> Using 0, ignoring 0
|
||||
> Warning: Virtual modifier ScrollLock multiply defined
|
||||
> Using 0, ignoring 0
|
||||
> Warning: Virtual modifier Hyper multiply defined
|
||||
> Using 0, ignoring 0
|
||||
> Warning: Virtual modifier ScrollLock multiply defined
|
||||
> Using 0, ignoring 0
|
||||
Errors from xkbcomp are not fatal to the X server
|
||||
Errors from xkbcomp are not fatal to the X server
|
||||
Authorization required, but no authorization protocol specified
|
||||
[2m2026-10-26T03:36:19.116806Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m X11 window manager attached
|
||||
[2m2026-10-26T03:36:19.116829Z[0m [32m INFO[0m [2mwo::electron[0m[2m:[0m Spawning Electron for window 'main' with config: WindowConfig { name: "main", system_shell: true, url: Some("http://127.0.0.1:5173"), html: None, css: None, js: None, width: 1280, height: 800, x: 0, y: 0, z_order: -1073741824, fps: 0, format: "ARGB8888", focusable: false, interactive: false, floating: false }
|
||||
[2m2026-10-26T03:36:19.116868Z[0m [32m INFO[0m [2mwo::electron[0m[2m:[0m Serialized config: {"name":"main","url":"http://127.0.0.1:5173","html":null,"css":null,"js":null,"width":1280,"height":800,"x":0,"y":0,"z_order":-1073741824,"fps":0,"format":"ARGB8888","focusable":false,"interactive":false,"floating":false}
|
||||
[2m2026-10-26T03:36:19.117076Z[0m [32m INFO[0m [2mwo::electron[0m[2m:[0m spawned Electron process [3mwindow[0m[2m=[0mmain [3mpid[0m[2m=[0m1378527 [3mrender_node[0m[2m=[0m"/dev/dri/renderD128" [3mozone[0m[2m=[0mwayland
|
||||
[Wo] Electron starting, NODE_VERSION= v24.22.0
|
||||
[Wo] GPU backend preference: auto; Ozone platform: wayland
|
||||
[Wo] Graphics process context: {"uid":1000,"groups":[4,24,27,30,44,46,100,117,125,995,1000],"env":{"WAYLAND_DISPLAY":"wo-0","DISPLAY":":1","XDG_SESSION_TYPE":"tty","XDG_RUNTIME_DIR":"/run/user/1000","EGL_PLATFORM":"wayland"},"renderNodeAccess":{"path":"/dev/dri/renderD128","mode":"660","readable":true,"writable":true},"switches":[{"name":"ozone-platform","present":true,"value":"wayland"},{"name":"use-gl","present":false,"value":""},{"name":"use-angle","present":false,"value":""},{"name":"disable-gpu","present":false,"value":""},{"name":"disable-vulkan","present":true,"value":""},{"name":"disable-features","present":true,"value":"Vulkan"}]}
|
||||
[Wo] WO_WINDOW_CONFIG env = {"name":"main","url":"http://127.0.0.1:5173","html":null,"css":null,"js":null,"width":1280,"height":800,"x":0,"y":0,"z_order":-1073741824,"fps":0,"format":"ARGB8888","focusable":false,"interactive":false,"floating":false}
|
||||
[Wo] WINDOW_CONFIG parsed = {"name":"main","url":"http://127.0.0.1:5173","html":null,"css":null,"js":null,"width":1280,"height":800,"x":0,"y":0,"z_order":-1073741824,"fps":0,"format":"ARGB8888","focusable":false,"interactive":false,"floating":false}
|
||||
[Wo] App ready, CLIENT_MODE= false WO_STANDALONE= undefined
|
||||
[Wo] GPU feature status: {"2d_canvas":"disabled_software","direct_rendering_display_compositor":"disabled_off_ok","gpu_compositing":"disabled_software","multiple_raster_threads":"enabled_force_on","opengl":"disabled_off","rasterization":"disabled_software","raw_draw":"enabled_on","skia_graphite":"disabled_off","trees_in_viz":"disabled_off","video_decode":"disabled_software","video_encode":"disabled_software","vulkan":"disabled_off","webgl":"disabled_off","webgpu":"disabled_off","webgpu_on_vk_via_gl_interop":"disabled_off","webnn":"disabled_off"}
|
||||
[Wo] GPU info: {"auxAttributes":{"amdSwitchable":false,"canSupportThreadedTextureMailbox":false,"glImplementationParts":"(gl=none,angle=none)","glResetNotificationStrategy":0,"hardwareSupportsVulkan":false,"inProcessGpu":true,"initializationTime":0,"isAsan":false,"isClangCoverage":false,"jpegDecodeAcceleratorSupported":false,"optimus":false,"passthroughCmdDecoder":false,"sandboxed":false,"skiaBackendType":"None","subpixelFontRendering":true,"targetCpuBits":64,"visibilityCallbackCallCount":0},"gpuDevice":[{"active":true,"deviceId":9352,"gpuPreference":0,"vendorId":4318}]}
|
||||
[Wo] Offscreen shared texture: disabled (GPU compositor=disabled_software, threaded texture mailbox=false, WebGL=disabled_off)
|
||||
[Wo] Will connect to compositor
|
||||
[Wo] Connecting to compositor IPC: /run/user/1000/wo-ipc.sock
|
||||
[Wo] Portal UI bridge listening at /run/user/1000/wo-portal-ui.sock
|
||||
[2m2026-10-26T03:36:19.251134Z[0m [32m INFO[0m [2mwo::electron[0m[2m:[0m Electron window connected [3mwindow[0m[2m=[0mmain
|
||||
[Wo] IPC connected, sent HELLO message
|
||||
[Wo] Initializing GBM on render node: /dev/dri/renderD128
|
||||
[Wo] DMABUF sender initialized
|
||||
[Wo] Creating window
|
||||
[Wo] createWindow starting
|
||||
127.0.0.1 - - [25/Oct/2026 23:36:19] "GET / HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [25/Oct/2026 23:36:19] "GET /_wo.capabilities.json HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [25/Oct/2026 23:36:19] "GET /_wo.components.css HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [25/Oct/2026 23:36:19] "GET /assets/index-HBQlw-Wm.js HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [25/Oct/2026 23:36:19] "GET /assets/index-D7GQWtqC.css HTTP/1.1" 200 -
|
||||
[Wo] Verified site rip.crit.comraw; requested capabilities: window-management, overlay-rendering, desktop-applications, xdg, dbus, portals, process-launch, system-control, notifications
|
||||
[Wo] Granted verified comraw system-shell capabilities
|
||||
[Wo] preloadPath=/home/hellings/.local/share/wo/electron/dist/preload.js
|
||||
[2m2026-10-26T03:36:19.290019Z[0m [32m INFO[0m [2mwo::state[0m[2m:[0m new_fractional_scale: setting preferred_scale(1.0)
|
||||
[2m2026-10-26T03:36:19.290063Z[0m [32m INFO[0m [2mwo::state[0m[2m:[0m new_fractional_scale: done
|
||||
[2m2026-10-26T03:36:19.290115Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m computed and registered document and stylesheet hashes [3mwindow[0m[2m=[0mmain [3mpage[0m[2m=[0mhttp://127.0.0.1:5173/
|
||||
[Wo] BrowserWindow created, CLIENT_MODE=false offscreen=true painting=true
|
||||
[Wo] setupIpcHandlers completed
|
||||
[Wo] Loading content URL: http://127.0.0.1:5173
|
||||
127.0.0.1 - - [25/Oct/2026 23:36:19] "GET / HTTP/1.1" 304 -
|
||||
[Wo] Locking verified script http://127.0.0.1:5173/_wo.components.css -> 8337fca251ea
|
||||
[Wo] Locking verified script http://127.0.0.1:5173/assets/index-HBQlw-Wm.js -> d1647de85605
|
||||
[Wo] Locking verified script http://127.0.0.1:5173/assets/index-D7GQWtqC.css -> c0e2f97ed4d1
|
||||
[1378559:1025/233619.349346:ERROR:ui/ozone/platform/wayland/gpu/wayland_surface_factory.cc:249] '--ozone-platform=wayland' is not compatible with Vulkan. Consider switching to '--ozone-platform=x11' or disabling Vulkan
|
||||
[Wo] Resource request failed: net::ERR_ABORTED stylesheet http://127.0.0.1:5173/_wo.components.css
|
||||
[Wo] Resource request failed: net::ERR_ABORTED stylesheet http://127.0.0.1:5173/assets/index-D7GQWtqC.css
|
||||
[Wo] Serving verified script 8337fca251ea (2190 bytes)
|
||||
[Wo] Resource request failed: net::ERR_ABORTED script http://127.0.0.1:5173/assets/index-HBQlw-Wm.js
|
||||
[Wo] Serving verified script c0e2f97ed4d1 (36664 bytes)
|
||||
[Wo] Serving verified script d1647de85605 (268639 bytes)
|
||||
[Wo] Content loaded from: http://127.0.0.1:5173
|
||||
127.0.0.1 - - [25/Oct/2026 23:36:19] "GET /elementary.jpg HTTP/1.1" 304 -
|
||||
[Wo] Page render state: {"readyState":"complete","title":"comraw","bodyText":"1\n2\n3\n4\nSun, Oct 25\n11:36 PM","bodyBackground":"rgba(0, 0, 0, 0)","bodyBackgroundImage":"none","bodyDisplay":"block","portalCardBackground":"","portalCardBounds":null,"buttons":12,"resourceConsentBridge":false,"rootChildren":1,"shellPresent":true,"wallpaperImage":"url(\"http://127.0.0.1:5173/elementary.jpg\")","compositorApi":true,"woApi":true}
|
||||
[Wo] Page initialized, setting up rendering
|
||||
[Wo] Offscreen frame pacing: 60 Hz (window cap auto)
|
||||
[Wo] createWindow completed successfully
|
||||
[Wo] Window created successfully
|
||||
[Wo] Ignoring empty offscreen paint 0x0; content=1280x800 dirty=0,0,1280,800
|
||||
[1378559:1025/233619.594500:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:438] Unable to initialize SkSurface
|
||||
[1378559:1025/233619.594551:ERROR:gpu/command_buffer/service/raster_decoder.cc:2939] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glBeginRasterCHROMIUM: failed to create surface
|
||||
[1378559:1025/233619.594579:ERROR:gpu/command_buffer/service/raster_decoder.cc:3001] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glRasterCHROMIUM: RasterCHROMIUM without BeginRasterCHROMIUM
|
||||
[1378559:1025/233619.594599:ERROR:gpu/command_buffer/service/raster_decoder.cc:3090] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glEndRasterCHROMIUM: EndRasterCHROMIUM without BeginRasterCHROMIUM
|
||||
[1378559:1025/233619.594683:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:438] Unable to initialize SkSurface
|
||||
[1378559:1025/233619.594712:ERROR:gpu/command_buffer/service/raster_decoder.cc:2939] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glBeginRasterCHROMIUM: failed to create surface
|
||||
[1378559:1025/233619.594738:ERROR:gpu/command_buffer/service/raster_decoder.cc:3001] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glRasterCHROMIUM: RasterCHROMIUM without BeginRasterCHROMIUM
|
||||
[1378559:1025/233619.594758:ERROR:gpu/command_buffer/service/raster_decoder.cc:3090] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glEndRasterCHROMIUM: EndRasterCHROMIUM without BeginRasterCHROMIUM
|
||||
[1378559:1025/233619.594801:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:438] Unable to initialize SkSurface
|
||||
[1378559:1025/233619.594816:ERROR:gpu/command_buffer/service/raster_decoder.cc:2939] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glBeginRasterCHROMIUM: failed to create surface
|
||||
[1378559:1025/233619.594828:ERROR:gpu/command_buffer/service/raster_decoder.cc:3001] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glRasterCHROMIUM: RasterCHROMIUM without BeginRasterCHROMIUM
|
||||
[1378559:1025/233619.594848:ERROR:gpu/command_buffer/service/raster_decoder.cc:3090] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glEndRasterCHROMIUM: EndRasterCHROMIUM without BeginRasterCHROMIUM
|
||||
[1378559:1025/233619.604006:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:438] Unable to initialize SkSurface
|
||||
[1378559:1025/233619.604035:ERROR:gpu/command_buffer/service/raster_decoder.cc:2939] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glBeginRasterCHROMIUM: failed to create surface
|
||||
[1378559:1025/233619.604047:ERROR:gpu/command_buffer/service/raster_decoder.cc:3001] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glRasterCHROMIUM: RasterCHROMIUM without BeginRasterCHROMIUM
|
||||
[1378559:1025/233619.604061:ERROR:gpu/command_buffer/service/raster_decoder.cc:3090] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glEndRasterCHROMIUM: EndRasterCHROMIUM without BeginRasterCHROMIUM
|
||||
[1378559:1025/233619.604410:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:593] Attempt to read from an uninitialized SharedImage. Initialized region: (0, 0, 0, 0) Size: (1280, 832)
|
||||
[1378559:1025/233619.607032:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:593] Attempt to read from an uninitialized SharedImage. Initialized region: (0, 0, 0, 0) Size: (1280, 64)
|
||||
[1378559:1025/233619.609323:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:593] Attempt to read from an uninitialized SharedImage. Initialized region: (0, 0, 0, 0) Size: (192, 256)
|
||||
[1378559:1025/233619.609467:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:593] Attempt to read from an uninitialized SharedImage. Initialized region: (0, 0, 0, 0) Size: (64, 64)
|
||||
[Wo] First bitmap pixels (BGRA top-left/mid-left/center/bottom-right): [[255,255,255,255],[255,255,255,255],[255,255,255,255],[255,255,255,255]]
|
||||
[Wo] First bitmap frame sent as DMA-BUF: seq=1 size=1280x800 stride=5120
|
||||
[Wo] Second bitmap pixels (BGRA top-left/mid-left/center/bottom-right): [[255,255,255,255],[255,255,255,255],[255,255,255,255],[255,255,255,255]]
|
||||
[Wo] SKIPPED paint due to frame backpressure inFlight=2
|
||||
[2m2026-10-26T03:36:19.634388Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m first Electron frame imported as a DMA-BUF texture [3mwindow[0m[2m=[0mmain [3mwidth[0m[2m=[0m1280 [3mheight[0m[2m=[0m800 [3mseq[0m[2m=[0m1
|
||||
[2m2026-10-26T03:36:19.634880Z[0m [32m INFO[0m [2mwo::cursor[0m[2m:[0m Loaded cursor 'default' (24x24, hotspot 3,1) from /usr/share/icons/Adwaita/cursors/default
|
||||
[2m2026-10-26T03:36:19.635561Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m first Electron texture rendered into the output frame [3mwindow[0m[2m=[0mmain [3mx[0m[2m=[0m0 [3my[0m[2m=[0m0 [3moutput_width[0m[2m=[0m1280 [3moutput_height[0m[2m=[0m800
|
||||
[2m2026-10-26T03:36:19.663496Z[0m [32m INFO[0m [2mwo::nested[0m[2m:[0m first nested output frame submitted successfully
|
||||
[1378559:1025/233619.899568:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:438] Unable to initialize SkSurface
|
||||
[1378559:1025/233619.899611:ERROR:gpu/command_buffer/service/raster_decoder.cc:2939] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glBeginRasterCHROMIUM: failed to create surface
|
||||
[1378559:1025/233619.899629:ERROR:gpu/command_buffer/service/raster_decoder.cc:3001] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glRasterCHROMIUM: RasterCHROMIUM without BeginRasterCHROMIUM
|
||||
[1378559:1025/233619.899638:ERROR:gpu/command_buffer/service/raster_decoder.cc:3090] [.RendererRasterWorker-0x91400051b00]GL ERROR :GL_INVALID_OPERATION : glEndRasterCHROMIUM: EndRasterCHROMIUM without BeginRasterCHROMIUM
|
||||
[1378559:1025/233619.900098:ERROR:gpu/command_buffer/service/shared_image/shared_image_representation.cc:593] Attempt to read from an uninitialized SharedImage. Initialized region: (0, 0, 0, 0) Size: (1280, 832)
|
||||
[Wo] Load timeout, continuing
|
||||
Serving HTTP on 127.0.0.1 port 5173 (http://127.0.0.1:5173/) ...
|
||||
|
||||
Authorization required, but no authorization protocol specified
|
||||
|
||||
Authorization required, but no authorization protocol specified
|
||||
|
||||
Authorization required, but no authorization protocol specified
|
||||
|
||||
Gtk-Message: 13:58:16.908: Failed to load module "appmenu-gtk-module"
|
||||
Gtk-Message: 13:58:16.908: Failed to load module "appmenu-gtk-module"
|
||||
Authorization required, but no authorization protocol specified
|
||||
|
||||
Authorization required, but no authorization protocol specified
|
||||
|
||||
[24415:0311/135817.250839:ERROR:ui/ozone/platform/wayland/gpu/wayland_surface_factory.cc:250] '--ozone-platform=wayland' is not compatible with Vulkan. Consider switching to '--ozone-platform=x11' or disabling Vulkan
|
||||
[24415:0311/135817.250839:ERROR:ui/ozone/platform/wayland/gpu/wayland_surface_factory.cc:250] '--ozone-platform=wayland' is not compatible with Vulkan. Consider switching to '--ozone-platform=x11' or disabling Vulkan
|
||||
[24415:0311/135817.264878:ERROR:components/viz/service/display_embedder/skia_output_device_vulkan.cc:301] Failed to create vulkan surface.
|
||||
[24415:0311/135817.264878:ERROR:components/viz/service/display_embedder/skia_output_device_vulkan.cc:301] Failed to create vulkan surface.
|
||||
[24330:0311/135939.036070:ERROR:content/browser/network_service_instance_impl.cc:613] Network service crashed, restarting service.
|
||||
[24330:0311/135939.036070:ERROR:content/browser/network_service_instance_impl.cc:613] Network service crashed, restarting service.
|
||||
[24330:0311/135939.185963:ERROR:content/browser/gpu/gpu_process_host.cc:999] GPU process exited unexpectedly: exit_code=15
|
||||
[24330:0311/135939.185963:ERROR:content/browser/gpu/gpu_process_host.cc:999] GPU process exited unexpectedly: exit_code=15
|
||||
Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed
|
||||
at s.send (node:electron/js2c/browser_init:2:100266)
|
||||
at b.send (node:electron/js2c/browser_init:2:84687)
|
||||
at Socket.<anonymous> (file:///home/hellings/Documents/wo/electron/dist/main.js:1213:36)
|
||||
at Socket.emit (node:events:519:28)
|
||||
at addChunk (node:internal/streams/readable:561:12)
|
||||
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
|
||||
at Readable.push (node:internal/streams/readable:392:5)
|
||||
at Pipe.onStreamRead (node:internal/stream_base_commons:189:23)
|
||||
Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed
|
||||
at s.send (node:electron/js2c/browser_init:2:100266)
|
||||
at b.send (node:electron/js2c/browser_init:2:84687)
|
||||
at Socket.<anonymous> (file:///home/hellings/Documents/wo/electron/dist/main.js:1213:36)
|
||||
at Socket.emit (node:events:519:28)
|
||||
at addChunk (node:internal/streams/readable:561:12)
|
||||
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
|
||||
at Readable.push (node:internal/streams/readable:392:5)
|
||||
at Pipe.onStreamRead (node:internal/stream_base_commons:189:23)
|
||||
Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed
|
||||
at s.send (node:electron/js2c/browser_init:2:100266)
|
||||
at b.send (node:electron/js2c/browser_init:2:84687)
|
||||
at Socket.<anonymous> (file:///home/hellings/Documents/wo/electron/dist/main.js:1235:40)
|
||||
at Socket.emit (node:events:519:28)
|
||||
at addChunk (node:internal/streams/readable:561:12)
|
||||
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
|
||||
at Readable.push (node:internal/streams/readable:392:5)
|
||||
at Pipe.onStreamRead (node:internal/stream_base_commons:189:23)
|
||||
Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed
|
||||
at s.send (node:electron/js2c/browser_init:2:100266)
|
||||
at b.send (node:electron/js2c/browser_init:2:84687)
|
||||
at Socket.<anonymous> (file:///home/hellings/Documents/wo/electron/dist/main.js:1235:40)
|
||||
at Socket.emit (node:events:519:28)
|
||||
at addChunk (node:internal/streams/readable:561:12)
|
||||
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
|
||||
at Readable.push (node:internal/streams/readable:392:5)
|
||||
at Pipe.onStreamRead (node:internal/stream_base_commons:189:23)
|
||||
Authorization required, but no authorization protocol specified
|
||||
|
||||
Authorization required, but no authorization protocol specified
|
||||
|
||||
Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed
|
||||
at s.send (node:electron/js2c/browser_init:2:100266)
|
||||
at b.send (node:electron/js2c/browser_init:2:84687)
|
||||
at Socket.<anonymous> (file:///home/hellings/Documents/wo/electron/dist/main.js:1213:36)
|
||||
at Socket.emit (node:events:519:28)
|
||||
at addChunk (node:internal/streams/readable:561:12)
|
||||
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
|
||||
at Readable.push (node:internal/streams/readable:392:5)
|
||||
at Pipe.onStreamRead (node:internal/stream_base_commons:189:23)
|
||||
Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed
|
||||
at s.send (node:electron/js2c/browser_init:2:100266)
|
||||
at b.send (node:electron/js2c/browser_init:2:84687)
|
||||
at Socket.<anonymous> (file:///home/hellings/Documents/wo/electron/dist/main.js:1213:36)
|
||||
at Socket.emit (node:events:519:28)
|
||||
at addChunk (node:internal/streams/readable:561:12)
|
||||
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
|
||||
at Readable.push (node:internal/streams/readable:392:5)
|
||||
at Pipe.onStreamRead (node:internal/stream_base_commons:189:23)
|
||||
Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed
|
||||
at s.send (node:electron/js2c/browser_init:2:100266)
|
||||
at b.send (node:electron/js2c/browser_init:2:84687)
|
||||
at Socket.<anonymous> (file:///home/hellings/Documents/wo/electron/dist/main.js:1235:40)
|
||||
at Socket.emit (node:events:519:28)
|
||||
at addChunk (node:internal/streams/readable:561:12)
|
||||
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
|
||||
at Readable.push (node:internal/streams/readable:392:5)
|
||||
at Pipe.onStreamRead (node:internal/stream_base_commons:189:23)
|
||||
Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed
|
||||
at s.send (node:electron/js2c/browser_init:2:100266)
|
||||
at b.send (node:electron/js2c/browser_init:2:84687)
|
||||
at Socket.<anonymous> (file:///home/hellings/Documents/wo/electron/dist/main.js:1235:40)
|
||||
at Socket.emit (node:events:519:28)
|
||||
at addChunk (node:internal/streams/readable:561:12)
|
||||
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
|
||||
at Readable.push (node:internal/streams/readable:392:5)
|
||||
at Pipe.onStreamRead (node:internal/stream_base_commons:189:23)
|
||||
Authorization required, but no authorization protocol specified
|
||||
|
||||
Authorization required, but no authorization protocol specified
|
||||
|
||||
Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed
|
||||
at s.send (node:electron/js2c/browser_init:2:100266)
|
||||
at b.send (node:electron/js2c/browser_init:2:84687)
|
||||
at Socket.<anonymous> (file:///home/hellings/Documents/wo/electron/dist/main.js:1213:36)
|
||||
at Socket.emit (node:events:519:28)
|
||||
at addChunk (node:internal/streams/readable:561:12)
|
||||
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
|
||||
at Readable.push (node:internal/streams/readable:392:5)
|
||||
at Pipe.onStreamRead (node:internal/stream_base_commons:189:23)
|
||||
Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed
|
||||
at s.send (node:electron/js2c/browser_init:2:100266)
|
||||
at b.send (node:electron/js2c/browser_init:2:84687)
|
||||
at Socket.<anonymous> (file:///home/hellings/Documents/wo/electron/dist/main.js:1213:36)
|
||||
at Socket.emit (node:events:519:28)
|
||||
at addChunk (node:internal/streams/readable:561:12)
|
||||
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
|
||||
at Readable.push (node:internal/streams/readable:392:5)
|
||||
at Pipe.onStreamRead (node:internal/stream_base_commons:189:23)
|
||||
Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed
|
||||
at s.send (node:electron/js2c/browser_init:2:100266)
|
||||
at b.send (node:electron/js2c/browser_init:2:84687)
|
||||
at Socket.<anonymous> (file:///home/hellings/Documents/wo/electron/dist/main.js:1235:40)
|
||||
at Socket.emit (node:events:519:28)
|
||||
at addChunk (node:internal/streams/readable:561:12)
|
||||
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
|
||||
at Readable.push (node:internal/streams/readable:392:5)
|
||||
at Pipe.onStreamRead (node:internal/stream_base_commons:189:23)
|
||||
Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed
|
||||
at s.send (node:electron/js2c/browser_init:2:100266)
|
||||
at b.send (node:electron/js2c/browser_init:2:84687)
|
||||
at Socket.<anonymous> (file:///home/hellings/Documents/wo/electron/dist/main.js:1235:40)
|
||||
at Socket.emit (node:events:519:28)
|
||||
at addChunk (node:internal/streams/readable:561:12)
|
||||
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
|
||||
at Readable.push (node:internal/streams/readable:392:5)
|
||||
at Pipe.onStreamRead (node:internal/stream_base_commons:189:23)
|
||||
Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed
|
||||
at s.send (node:electron/js2c/browser_init:2:100266)
|
||||
at b.send (node:electron/js2c/browser_init:2:84687)
|
||||
at Socket.<anonymous> (file:///home/hellings/Documents/wo/electron/dist/main.js:1213:36)
|
||||
at Socket.emit (node:events:519:28)
|
||||
at addChunk (node:internal/streams/readable:561:12)
|
||||
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
|
||||
at Readable.push (node:internal/streams/readable:392:5)
|
||||
at Pipe.onStreamRead (node:internal/stream_base_commons:189:23)
|
||||
Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed
|
||||
at s.send (node:electron/js2c/browser_init:2:100266)
|
||||
at b.send (node:electron/js2c/browser_init:2:84687)
|
||||
at Socket.<anonymous> (file:///home/hellings/Documents/wo/electron/dist/main.js:1213:36)
|
||||
at Socket.emit (node:events:519:28)
|
||||
at addChunk (node:internal/streams/readable:561:12)
|
||||
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
|
||||
at Readable.push (node:internal/streams/readable:392:5)
|
||||
at Pipe.onStreamRead (node:internal/stream_base_commons:189:23)
|
||||
Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed
|
||||
at s.send (node:electron/js2c/browser_init:2:100266)
|
||||
at b.send (node:electron/js2c/browser_init:2:84687)
|
||||
at Socket.<anonymous> (file:///home/hellings/Documents/wo/electron/dist/main.js:1235:40)
|
||||
at Socket.emit (node:events:519:28)
|
||||
at addChunk (node:internal/streams/readable:561:12)
|
||||
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
|
||||
at Readable.push (node:internal/streams/readable:392:5)
|
||||
at Pipe.onStreamRead (node:internal/stream_base_commons:189:23)
|
||||
Error sending from webFrameMain: Error: Render frame was disposed before WebFrameMain could be accessed
|
||||
at s.send (node:electron/js2c/browser_init:2:100266)
|
||||
at b.send (node:electron/js2c/browser_init:2:84687)
|
||||
at Socket.<anonymous> (file:///home/hellings/Documents/wo/electron/dist/main.js:1235:40)
|
||||
at Socket.emit (node:events:519:28)
|
||||
at addChunk (node:internal/streams/readable:561:12)
|
||||
at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
|
||||
at Readable.push (node:internal/streams/readable:392:5)
|
||||
at Pipe.onStreamRead (node:internal/stream_base_commons:189:23)
|
||||
(EE) failed to read Wayland events: Broken pipe
|
||||
(EE) failed to read Wayland events: Broken pipe
|
||||
Keyboard interrupt received, exiting.
|
||||
[Wo] IPC connection closed
|
||||
Gdk-Message: 23:36:25.499: Error reading events from display: Broken pipe
|
||||
|
|
|
|||
Loading…
Reference in a new issue