diff --git a/.dockerignore b/.dockerignore index 5788dae..489d5bd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,7 @@ !catalog/** !registry/** !desktop/packaging/** +!de/** !training/** !bunfig.toml !scripts/scan-packages.mjs diff --git a/Dockerfile b/Dockerfile index 1470ed8..584612e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,7 @@ COPY --chown=bun:bun catalog ./catalog COPY --chown=bun:bun registry ./registry # Agentic Desktop Layer installer served at /desktop/install.sh. COPY --chown=bun:bun desktop/packaging ./desktop/packaging +COPY --chown=bun:bun de ./de # W3BS Training: the hosted workspace app (src/server.mjs mounts training/hosted-routes.mjs) # and the installers served at /training/install.sh and /training/install.ps1. COPY --chown=bun:bun training ./training diff --git a/de/install.sh b/de/install.sh new file mode 100755 index 0000000..5259d30 --- /dev/null +++ b/de/install.sh @@ -0,0 +1,209 @@ +#!/bin/sh +# W3BS DE installer: a minimal, agent-friendly Wayland desktop built on Hyprland. +# +# curl -fsSL https://w3bs.org/de/install.sh | sh +# curl -fsSL https://w3bs.org/de/install.sh | sh -s -- --yes # no questions +# +# What it does: +# 1. installs Hyprland and the small set of tools the desktop uses (bar, launcher, +# notifications, lock, idle, wallpaper, terminal, screenshots), with your package manager; +# 2. installs the W3BS DE defaults to ~/.local/share/w3bs-de and the `w3bs-de` command; your own +# settings live in ~/.config/w3bs-de and are never overwritten; +# 3. adds "W3BS DE" to your login screen; +# 4. installs the Agentic Desktop Layer (w3bs.org/desktop), so agents can use this desktop +# when, and only when, you turn agent access on. +# +# Options: +# --yes do not ask before installing packages +# --no-packages only install or update the W3BS DE files (what `w3bs-de update` runs) +# --no-agents skip the Agentic Desktop Layer +# --no-session do not add the login-screen entry (needs sudo) +# Environment: W3BS_DE_BASE (default https://w3bs.org/de), W3BS_DE_SOURCE (a local de/share +# directory instead of downloading; for test rigs). +set -eu + +BASE="${W3BS_DE_BASE:-https://w3bs.org/de}" +SHARE="${W3BS_DE_SHARE:-$HOME/.local/share/w3bs-de}" +BIN="$HOME/.local/bin" +YES=0 PACKAGES=1 AGENTS=1 SESSION=1 +for a in "$@"; do + case "$a" in + --yes|-y) YES=1 ;; + --no-packages) PACKAGES=0 ;; + --no-agents) AGENTS=0 ;; + --no-session) SESSION=0 ;; + -h|--help) sed -n '2,24p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "w3bs-de install: unknown option $a" >&2; exit 2 ;; + esac +done + +say() { printf 'w3bs-de: %s\n' "$*"; } +die() { printf 'w3bs-de: %s\n' "$*" >&2; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } + +[ "$(uname -s)" = Linux ] || die "W3BS DE is a Linux desktop (Wayland). On macOS and Windows use the Agentic Desktop Layer: https://w3bs.org/desktop" +[ "$(id -u)" -ne 0 ] || die "run this as your own user, not root (it asks for sudo when it needs it)" + +SUDO="" +if [ "$PACKAGES" = 1 ] || [ "$SESSION" = 1 ]; then + if have sudo; then SUDO=sudo; elif have doas; then SUDO=doas; fi +fi + +confirm() { + [ "$YES" = 1 ] && return 0 + [ -r /dev/tty ] || { say "no terminal to ask on; re-run with --yes"; return 1; } + printf 'w3bs-de: %s [Y/n] ' "$1" >/dev/tty + read -r ans /dev/null || true +ids=" ${ID:-} ${ID_LIKE:-} " +case "$ids" in + *" nixos "*) family=nixos ;; + *" arch "*|*" archlinux "*|*" manjaro "*|*" endeavouros "*|*" cachyos "*) family=arch ;; + *" fedora "*) family=fedora ;; + *" alpine "*|*" postmarketos "*) family=alpine ;; + *" opensuse"*|*" suse "*) family=suse ;; + *" debian "*|*" ubuntu "*) family=debian ;; + *) family=unknown ;; +esac + +common="xdg-desktop-portal-hyprland hyprlock hypridle waybar fuzzel swaybg foot grim slurp wl-clipboard brightnessctl wireplumber pavucontrol" +case "$family" in + arch) pkgs="hyprland $common hyprpolkitagent xdg-desktop-portal-gtk swaync libnotify noto-fonts ttf-dejavu" ;; + fedora) pkgs="hyprland $common hyprpolkitagent xdg-desktop-portal-gtk SwayNotificationCenter libnotify google-noto-sans-fonts dejavu-sans-fonts" ;; + debian) pkgs="hyprland $common hyprpolkitagent xdg-desktop-portal-gtk sway-notification-center libnotify-bin fonts-noto-core fonts-dejavu-core" ;; + alpine) pkgs="hyprland $common xdg-desktop-portal-gtk swaync libnotify font-noto font-dejavu dbus seatd" ;; + suse) pkgs="hyprland $common xdg-desktop-portal-gtk SwayNotificationCenter libnotify-tools noto-sans-fonts dejavu-fonts" ;; + *) pkgs="" ;; +esac + +available() { + case "$family" in + debian) apt-cache show "$1" >/dev/null 2>&1 ;; + fedora) dnf -q info "$1" >/dev/null 2>&1 ;; + alpine) [ -n "$(apk search -x "$1" 2>/dev/null)" ] ;; + suse) zypper -q info "$1" 2>/dev/null | grep -q "^Name" ;; + *) return 0 ;; + esac +} + +install_packages() { + case "$family" in + nixos) + say "NixOS: add this to configuration.nix and rebuild, then re-run with --no-packages:" + echo ' programs.hyprland.enable = true;' + echo ' environment.systemPackages = with pkgs; [ hyprlock hypridle waybar fuzzel swaynotificationcenter swaybg foot grim slurp wl-clipboard brightnessctl pavucontrol hyprpolkitagent libnotify ];' + return 0 ;; + unknown) say "unknown distribution (${ID:-?}); install Hyprland and these yourself, then re-run with --no-packages: $common swaync"; return 0 ;; + esac + [ -n "$SUDO" ] || die "installing packages needs sudo or doas (or re-run with --no-packages)" + if [ "$family" = fedora ] && ! dnf -q repolist 2>/dev/null | grep -qi hyprland; then + say "Fedora ships Hyprland through the COPR lionheartp/Hyprland (the one the Hyprland wiki lists)" + confirm "enable COPR lionheartp/Hyprland?" || die "Hyprland is needed" + $SUDO dnf -y copr enable lionheartp/Hyprland + fi + [ "$family" = debian ] && $SUDO apt-get update -qq + want="" skip="" + for p in $pkgs; do + if available "$p"; then want="$want $p"; else skip="$skip $p"; fi + done + case " $want " in + *" hyprland "*) ;; + *) case "$family" in + debian) die "this release has no hyprland package. Debian: use trixie-backports (0.55+), forky or sid; Ubuntu: 26.10 or later. https://wiki.hypr.land/Getting-Started/Installation/" ;; + alpine) die "enable the community repository (edge has Hyprland): https://wiki.alpinelinux.org/wiki/Repositories" ;; + *) die "no hyprland package found" ;; + esac ;; + esac + [ -z "$skip" ] || say "not available here, skipped:$skip" + say "packages:$want" + confirm "install them with $SUDO?" || die "cancelled" + case "$family" in + arch) $SUDO pacman -S --needed --noconfirm $want ;; + fedora) $SUDO dnf -y install $want ;; + debian) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $want ;; + alpine) $SUDO apk add $want ;; + suse) $SUDO zypper -n install $want ;; + esac +} + +[ "$PACKAGES" = 0 ] || install_packages + +# ---------------------------------------------------------------- the W3BS DE files + +sha256() { if have sha256sum; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi; } +fetch() { curl -fsSL --retry 2 "$1" -o "$2"; } + +stage="$SHARE.new.$$" +rm -rf "$stage" +mkdir -p "$stage" +trap 'rm -rf "$stage"' EXIT +if [ -n "${W3BS_DE_SOURCE:-}" ]; then + cp -R "$W3BS_DE_SOURCE/." "$stage/" + version="$(cat "$W3BS_DE_SOURCE/VERSION" 2>/dev/null || echo dev)" +else + have curl || die "curl is needed" + fetch "$BASE/manifest.txt" "$stage/.manifest" || die "cannot download $BASE/manifest.txt" + version="$(sed -n 's/^# version //p' "$stage/.manifest")" + grep -v '^#' "$stage/.manifest" | while read -r sum path; do + case "$path" in ""|/*|*..*) die "bad manifest path: $path" ;; esac + mkdir -p "$stage/$(dirname "$path")" + fetch "$BASE/files/$path" "$stage/$path" || die "download failed: $path" + [ "$(sha256 "$stage/$path")" = "$sum" ] || die "checksum mismatch: $path" + done + rm -f "$stage/.manifest" +fi +# Paths and the version are filled in here, so the configs need no environment to find them. +for f in "$stage"/default/hypr/*.conf "$stage/bin/w3bs-de"; do + [ -f "$f" ] && sed -i "s|@W3BS_DE_SHARE@|$SHARE|g; s|@W3BS_DE_VERSION@|$version|g" "$f" +done +chmod 755 "$stage/bin/w3bs-de" +rm -rf "$SHARE.old" +[ -d "$SHARE" ] && mv "$SHARE" "$SHARE.old" +mv "$stage" "$SHARE" +rm -rf "$SHARE.old" +trap - EXIT +mkdir -p "$BIN" +ln -sf "$SHARE/bin/w3bs-de" "$BIN/w3bs-de" +say "installed W3BS DE $version to $SHARE (your settings: ${XDG_CONFIG_HOME:-$HOME/.config}/w3bs-de)" + +# ---------------------------------------------------------------- login screen + +if [ "$SESSION" = 1 ] && [ "$PACKAGES" = 1 ] && [ -n "$SUDO" ] && [ ! -f /usr/share/wayland-sessions/w3bs-de.desktop ]; then + if confirm "add \"W3BS DE\" to the login screen (/usr/share/wayland-sessions)?"; then + # Each user's own copy of W3BS DE runs; this wrapper only finds it. + printf '#!/bin/sh\nexec "${W3BS_DE_SHARE:-$HOME/.local/share/w3bs-de}/bin/w3bs-de" "$@"\n' | $SUDO tee /usr/local/bin/w3bs-de >/dev/null + $SUDO chmod 755 /usr/local/bin/w3bs-de + $SUDO mkdir -p /usr/share/wayland-sessions + printf '[Desktop Entry]\nName=W3BS DE\nComment=Minimal, agent-friendly Wayland desktop built on Hyprland (w3bs.org/de)\nExec=/usr/local/bin/w3bs-de session\nType=Application\nDesktopNames=Hyprland\n' | $SUDO tee /usr/share/wayland-sessions/w3bs-de.desktop >/dev/null + say "added \"W3BS DE\" to the login screen" + fi +fi + +# ---------------------------------------------------------------- agents + +if [ "$AGENTS" = 1 ]; then + need=1 + if have w3bs-desktop || [ -x "$BIN/w3bs-desktop" ]; then + v="$("$(command -v w3bs-desktop || echo "$BIN/w3bs-desktop")" --version 2>/dev/null | sed 's/^w3bs desktop //')" + case "$v" in 0.[0-7].*|"") need=1 ;; *) need=0 ;; esac + fi + if [ "$need" = 1 ]; then + say "installing the Agentic Desktop Layer (Hyprland support needs 0.8 or later)" + curl -fsSL https://w3bs.org/desktop/install.sh | sh || say "the Agentic Desktop Layer did not install; retry: curl -fsSL https://w3bs.org/desktop/install.sh | sh" + fi +fi + +cat <&2; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } +running() { pgrep -u "$(id -u)" -x "$1" >/dev/null 2>&1; } +# Start a session helper once, detached from this shell. +spawn() { running "$1" || (setsid "$@" >/dev/null 2>&1 &); } + +# 0.55 moved Hyprland's config to Lua; older releases read hyprlang. +hypr_version() { Hyprland --version 2>/dev/null | sed -n 's/^Hyprland \([0-9][0-9.]*\).*/\1/p' | head -n1; } +hypr_lua() { + v="$(hypr_version)" + [ -n "$v" ] || return 0 + major="${v%%.*}"; rest="${v#*.}"; minor="${rest%%.*}" + [ "$major" -gt 0 ] || [ "$minor" -ge 55 ] +} + +# Your entry file: loads the defaults, then your own file. Written once, never overwritten. +ensure_user_config() { + mkdir -p "$CONF/hypr" "$STATE" + [ -f "$CONF/hypr/hyprland.lua" ] || cat > "$CONF/hypr/hyprland.lua" < "$CONF/hypr/user.lua" <<'EOF' +-- Your W3BS DE settings. Loaded after the defaults; anything here wins. +-- The Hyprland Lua reference: https://wiki.hypr.land/Configuring/Start/ +-- +-- hl.monitor({ output = "DP-1", mode = "2560x1440@144", position = "0x0", scale = 1 }) +-- hl.config({ input = { kb_layout = "us,de", kb_options = "grp:alts_toggle" } }) +-- w3bs.bind("SUPER + E", "Files", "nautilus") +EOF + [ -f "$CONF/hypr/hyprland.conf" ] || cat > "$CONF/hypr/hyprland.conf" < "$CONF/hypr/user.conf" +} + +cmd_session() { + have Hyprland || die "Hyprland is not installed; run: curl -fsSL https://w3bs.org/de/install.sh | sh" + ensure_user_config + export XDG_CURRENT_DESKTOP=Hyprland XDG_SESSION_TYPE=wayland XDG_SESSION_DESKTOP=w3bs-de W3BS_DE=1 + if hypr_lua; then cfg="$CONF/hypr/hyprland.lua"; else cfg="$CONF/hypr/hyprland.conf"; fi + exec Hyprland --config "$cfg" +} + +wallpaper_file() { + if [ -f "$CONF/wallpaper" ] && [ -f "$(cat "$CONF/wallpaper")" ]; then cat "$CONF/wallpaper"; else printf '%s' "$SHARE/wallpaper/w3bs-dark.png"; fi +} + +polkit_agent() { + for p in hyprpolkitagent /usr/lib/hyprpolkitagent/hyprpolkitagent /usr/libexec/hyprpolkitagent lxqt-policykit-agent /usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1 /usr/libexec/polkit-gnome-authentication-agent-1; do + if have "$p" || [ -x "$p" ]; then spawn "$p"; return; fi + done +} + +# Newer foot (1.28 here) names the section [colors-dark] and rejects [colors]; older foot only knows +# [colors]. Keep whichever this foot accepts. +fix_foot() { + f="$D/foot/foot.ini" + have foot && [ -f "$f" ] || return 0 + foot --check-config --config="$f" >/dev/null 2>&1 && return 0 + sed -i 's/^\[colors\]$/[colors-dark]/' "$f" + foot --check-config --config="$f" >/dev/null 2>&1 || sed -i 's/^\[colors-dark\]$/[colors]/' "$f" +} + +cmd_autostart() { + ensure_user_config + fix_foot + have swaybg && spawn swaybg -m fill -i "$(wallpaper_file)" + have waybar && spawn waybar -c "$D/waybar/config.jsonc" -s "$D/waybar/style.css" + have swaync && spawn swaync -c "$D/swaync/config.json" -s "$D/swaync/style.css" + [ -f "$STATE/agent-mode" ] || { have hypridle && spawn hypridle -c "$D/hypr/hypridle.conf"; } + polkit_agent + # The agent broker (Disabled until you enable it) must see this session's environment to + # find Hyprland, so restart it now that the environment is imported. + if have w3bs-desktop; then + if have systemctl && systemctl --user cat w3bs-desktop.service >/dev/null 2>&1; then + systemctl --user restart w3bs-desktop.service + else + # Stop the old broker and wait for it: a new one refuses a socket that is still served. + pkill -u "$(id -u)" -f "w3bs-desktop daemon" 2>/dev/null + i=0 + while pgrep -u "$(id -u)" -f "w3bs-desktop daemon" >/dev/null 2>&1 && [ $i -lt 25 ]; do sleep 0.2; i=$((i + 1)); done + (setsid w3bs-desktop daemon --adapter auto --mcp-http 127.0.0.1:7801 >"$STATE/broker.log" 2>&1 &) + fi + (setsid w3bs-desktop tray --wait-for-broker 60 >/dev/null 2>&1 &) + fi +} + +cmd_agents_status() { + if have w3bs-desktop && out="$(w3bs-desktop status --waybar 2>/dev/null)"; then + printf '%s\n' "$out" + else + printf '{"text":"agents ?","class":"off","tooltip":"The Agentic Desktop Layer is not running (w3bs-de doctor)"}\n' + fi +} + +cmd_agent_mode() { + mkdir -p "$STATE" + case "${1:-status}" in + on) + touch "$STATE/agent-mode"; pkill -u "$(id -u)" -x hypridle 2>/dev/null + say "Agent mode ON: the screen will not lock or blank on idle, so unattended agents are not paused." + say "Only use it on a machine you trust where you are; 'w3bs-de agent-mode off' restores locking." ;; + off) + rm -f "$STATE/agent-mode"; have hypridle && spawn hypridle -c "$D/hypr/hypridle.conf" + say "Agent mode off: idle dims, locks after 10 minutes and blanks after 15." ;; + status) if [ -f "$STATE/agent-mode" ]; then say on; else say off; fi ;; + *) die "usage: w3bs-de agent-mode on|off|status" ;; + esac +} + +cmd_wallpaper() { + mkdir -p "$CONF" + if [ $# -eq 0 ]; then rm -f "$CONF/wallpaper"; else + f="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"; [ -f "$f" ] || die "no such file: $1"; printf '%s' "$f" > "$CONF/wallpaper" + fi + pkill -u "$(id -u)" -x swaybg 2>/dev/null + have swaybg && (setsid swaybg -m fill -i "$(wallpaper_file)" >/dev/null 2>&1 &) + say "wallpaper: $(wallpaper_file)" +} + +cmd_screenshot() { + have grim || die "grim is not installed" + case "${1:-region}" in + region) have slurp || die "slurp is not installed"; g="$(slurp)" || exit 0; grim -g "$g" - | wl-copy --type image/png && notify-send -a w3bs-de "Screenshot copied" 2>/dev/null ;; + screen) dir="${XDG_PICTURES_DIR:-$HOME/Pictures}/Screenshots"; mkdir -p "$dir"; f="$dir/$(date +%Y-%m-%d_%H-%M-%S).png"; grim "$f" && notify-send -a w3bs-de "Screenshot saved" "$f" 2>/dev/null ;; + *) die "usage: w3bs-de screenshot region|screen" ;; + esac +} + +cmd_logout() { + r="$(hyprctl dispatch 'hl.dsp.exit()' 2>&1)" + case "$r" in ok*) exit 0 ;; esac + r="$(hyprctl dispatch exit 2>&1)" + case "$r" in ok*) exit 0 ;; esac + pkill -u "$(id -u)" -x Hyprland +} + +cmd_doctor() { + rc=0 + ok() { printf ' ok %s\n' "$*"; } + bad() { printf ' MISS %s\n' "$*"; rc=1; } + say "W3BS DE $VERSION ($SHARE)" + v="$(hypr_version)" + if [ -n "$v" ]; then + if hypr_lua; then ok "Hyprland $v (Lua config)"; else ok "Hyprland $v (hyprlang config; 0.55+ recommended)"; fi + else bad "Hyprland (https://wiki.hypr.land/Getting-Started/Installation/)"; fi + fix_foot + for c in waybar fuzzel swaync swaybg hyprlock hypridle foot grim slurp wl-copy; do + if have "$c"; then ok "$c"; else bad "$c"; fi + done + if have w3bs-desktop; then + ok "w3bs-desktop $(w3bs-desktop --version 2>/dev/null | sed 's/^w3bs desktop //')" + if [ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]; then + w3bs-desktop capabilities 2>/dev/null | grep -q linux-hyprland && ok "agent layer drives this session (linux-hyprland)" || bad "agent layer is not on the Hyprland backend; restart it: systemctl --user restart w3bs-desktop" + fi + else bad "w3bs-desktop (curl -fsSL https://w3bs.org/desktop/install.sh | sh)"; fi + [ -f "$STATE/agent-mode" ] && say " note agent mode is ON: idle locking is off" + return $rc +} + +case "${1:-help}" in + session) cmd_session ;; + autostart) cmd_autostart ;; + launcher) exec fuzzel --config="$D/fuzzel/fuzzel.ini" ;; + agents-status) cmd_agents_status ;; + agent-mode) shift; cmd_agent_mode "$@" ;; + wallpaper) shift; cmd_wallpaper "$@" ;; + screenshot) shift; cmd_screenshot "$@" ;; + logout) cmd_logout ;; + doctor) cmd_doctor ;; + update) shift; curl -fsSL https://w3bs.org/de/install.sh | sh -s -- --no-packages "$@" ;; + version|--version|-V) say "w3bs-de $VERSION" ;; + help|--help|-h) sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//' ;; + *) die "unknown command: $1 (w3bs-de help)" ;; +esac diff --git a/de/share/default/foot/foot.ini b/de/share/default/foot/foot.ini new file mode 100644 index 0000000..9c16077 --- /dev/null +++ b/de/share/default/foot/foot.ini @@ -0,0 +1,23 @@ +# W3BS DE terminal (foot). +font=monospace:size=11 +pad=10x8 + +[colors] +background=1c2b26 +foreground=f6f4ed +regular0=1c2b26 +regular1=d95231 +regular2=678c4f +regular3=d9a441 +regular4=5b8aa6 +regular5=a66b8a +regular6=5ba69a +regular7=dbded0 +bright0=6b7268 +bright1=e8775a +bright2=8fb573 +bright3=e8c06a +bright4=82aac2 +bright5=c28fab +bright6=82c2b8 +bright7=f6f4ed diff --git a/de/share/default/fuzzel/fuzzel.ini b/de/share/default/fuzzel/fuzzel.ini new file mode 100644 index 0000000..5c23d03 --- /dev/null +++ b/de/share/default/fuzzel/fuzzel.ini @@ -0,0 +1,26 @@ +# W3BS DE launcher (fuzzel). +[main] +font=sans:size=12 +prompt="w3bs › " +terminal=foot +width=44 +lines=10 +horizontal-pad=18 +vertical-pad=14 +inner-pad=8 +icons-enabled=yes + +[colors] +background=1c2b26f2 +text=f6f4edff +prompt=d95231ff +input=f6f4edff +match=d95231ff +selection=2c403aff +selection-text=f6f4edff +selection-match=e8775aff +border=d95231ff + +[border] +width=2 +radius=6 diff --git a/de/share/default/hypr/hypridle.conf b/de/share/default/hypr/hypridle.conf new file mode 100644 index 0000000..0e7ee15 --- /dev/null +++ b/de/share/default/hypr/hypridle.conf @@ -0,0 +1,21 @@ +# W3BS DE idle: dim, lock, screen off. `w3bs-de agent-mode on` stops this while agents work +# unattended (the lock would pause them); `w3bs-de agent-mode off` starts it again. +general { + lock_cmd = pidof hyprlock || hyprlock --config @W3BS_DE_SHARE@/default/hypr/hyprlock.conf + before_sleep_cmd = loginctl lock-session + after_sleep_cmd = hyprctl dispatch dpms on +} +listener { + timeout = 300 + on-timeout = brightnessctl -s set 30% + on-resume = brightnessctl -r +} +listener { + timeout = 600 + on-timeout = loginctl lock-session +} +listener { + timeout = 900 + on-timeout = hyprctl dispatch dpms off + on-resume = hyprctl dispatch dpms on +} diff --git a/de/share/default/hypr/hyprlock.conf b/de/share/default/hypr/hyprlock.conf new file mode 100644 index 0000000..b465811 --- /dev/null +++ b/de/share/default/hypr/hyprlock.conf @@ -0,0 +1,36 @@ +# W3BS DE lock screen. +general { + hide_cursor = true + grace = 0 +} +background { + monitor = + path = @W3BS_DE_SHARE@/wallpaper/w3bs-dark.png + color = rgb(1c2b26) +} +label { + monitor = + text = cmd[update:30000] date +"%H:%M" + color = rgb(f6f4ed) + font_size = 72 + font_family = Georgia, serif + position = 0, 160 + halign = center + valign = center +} +input-field { + monitor = + size = 320, 52 + outline_thickness = 2 + outer_color = rgb(d95231) + inner_color = rgb(1c2b26) + font_color = rgb(f6f4ed) + check_color = rgb(678c4f) + fail_color = rgb(d95231) + placeholder_text = Password + fade_on_empty = false + rounding = 6 + position = 0, -40 + halign = center + valign = center +} diff --git a/de/share/default/hypr/w3bs.conf b/de/share/default/hypr/w3bs.conf new file mode 100644 index 0000000..72ba3cb --- /dev/null +++ b/de/share/default/hypr/w3bs.conf @@ -0,0 +1,138 @@ +# W3BS DE defaults for Hyprland before 0.55 (hyprlang). https://w3bs.org/de +# Hyprland 0.55 and later load w3bs.lua instead. Do not edit: `w3bs-de update` replaces this file. +# Your settings go in ~/.config/w3bs-de/hypr/user.conf, sourced after this one. + +$share = @W3BS_DE_SHARE@ +$d = $share/default +$terminal = foot --config=$d/foot/foot.ini +$launcher = fuzzel --config=$d/fuzzel/fuzzel.ini + +env = XDG_CURRENT_DESKTOP,Hyprland +env = XDG_SESSION_TYPE,wayland +env = XDG_SESSION_DESKTOP,w3bs-de +env = W3BS_DE,1 +env = W3BS_DE_SHARE,$share +env = GDK_BACKEND,wayland,x11,* +env = QT_QPA_PLATFORM,wayland;xcb +env = MOZ_ENABLE_WAYLAND,1 +env = ELECTRON_OZONE_PLATFORM_HINT,wayland +env = XCURSOR_SIZE,24 +env = HYPRCURSOR_SIZE,24 + +monitor = , preferred, auto, auto + +general { + gaps_in = 4 + gaps_out = 8 + border_size = 2 + col.active_border = rgb(d95231) + col.inactive_border = rgba(6b726899) + resize_on_border = true + layout = dwindle + allow_tearing = false +} +decoration { + rounding = 6 + shadow { + enabled = false + } + blur { + enabled = false + } +} +animations { + enabled = true + bezier = w3bs, 0.2, 1, 0.3, 1 + animation = global, 1, 4, w3bs + animation = windows, 1, 2.5, w3bs, popin 92% + animation = fade, 1, 2.5, w3bs + animation = workspaces, 0 +} +dwindle { + preserve_split = true + force_split = 2 +} +input { + kb_layout = us + follow_mouse = 1 + sensitivity = 0 + touchpad { + natural_scroll = true + } +} +misc { + disable_hyprland_logo = true + disable_splash_rendering = true + background_color = rgb(1c2b26) + focus_on_activate = true + allow_session_lock_restore = true +} +ecosystem { + no_update_news = true + no_donation_nag = true +} +xwayland { + force_zero_scaling = true +} + +windowrule = match:class ^(pavucontrol|nm-connection-editor|blueman-manager|org.pulseaudio.pavucontrol)$, float on +windowrule = match:class ^(hyprpolkitagent|polkit.*|gcr-prompter|pinentry.*)$, float on, center on, pin on +windowrule = match:title ^(Agentic Desktop Layer.*)$, float on + +bind = SUPER, RETURN, exec, $terminal +bind = SUPER, SPACE, exec, $launcher +bind = SUPER, B, exec, xdg-open https://w3bs.org +bind = SUPER, N, exec, swaync-client -t -sw +bind = SUPER, W, killactive +bind = SUPER, Q, killactive +bind = SUPER, T, togglefloating +bind = SUPER, F, fullscreen, 0 +bind = SUPER, J, layoutmsg, togglesplit +bind = SUPER, ESCAPE, exec, hyprlock --config $d/hypr/hyprlock.conf +bind = SUPER SHIFT, ESCAPE, exec, w3bs-de logout +bind = , PRINT, exec, w3bs-de screenshot region +bind = SHIFT, PRINT, exec, w3bs-de screenshot screen +bind = SUPER, LEFT, movefocus, l +bind = SUPER, RIGHT, movefocus, r +bind = SUPER, UP, movefocus, u +bind = SUPER, DOWN, movefocus, d +bind = SUPER SHIFT, LEFT, swapwindow, l +bind = SUPER SHIFT, RIGHT, swapwindow, r +bind = SUPER SHIFT, UP, swapwindow, u +bind = SUPER SHIFT, DOWN, swapwindow, d +bind = SUPER, code:10, workspace, 1 +bind = SUPER, code:11, workspace, 2 +bind = SUPER, code:12, workspace, 3 +bind = SUPER, code:13, workspace, 4 +bind = SUPER, code:14, workspace, 5 +bind = SUPER, code:15, workspace, 6 +bind = SUPER, code:16, workspace, 7 +bind = SUPER, code:17, workspace, 8 +bind = SUPER, code:18, workspace, 9 +bind = SUPER SHIFT, code:10, movetoworkspace, 1 +bind = SUPER SHIFT, code:11, movetoworkspace, 2 +bind = SUPER SHIFT, code:12, movetoworkspace, 3 +bind = SUPER SHIFT, code:13, movetoworkspace, 4 +bind = SUPER SHIFT, code:14, movetoworkspace, 5 +bind = SUPER SHIFT, code:15, movetoworkspace, 6 +bind = SUPER SHIFT, code:16, movetoworkspace, 7 +bind = SUPER SHIFT, code:17, movetoworkspace, 8 +bind = SUPER SHIFT, code:18, movetoworkspace, 9 +bind = SUPER, TAB, workspace, m+1 +bind = SUPER SHIFT, TAB, workspace, m-1 +bindm = SUPER, mouse:272, movewindow +bindm = SUPER, mouse:273, resizewindow + +bind = SUPER, A, exec, w3bs-desktop control --open +bind = SUPER SHIFT, A, togglespecialworkspace, agent +bind = SUPER ALT, A, movetoworkspacesilent, special:agent +bind = SUPER ALT, ESCAPE, exec, w3bs-desktop disable + +bindel = , XF86AudioRaiseVolume, exec, wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+ +bindel = , XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%- +bindl = , XF86AudioMute, exec, wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle +bindel = , XF86MonBrightnessUp, exec, brightnessctl -e4 -n2 set 5%+ +bindel = , XF86MonBrightnessDown, exec, brightnessctl -e4 -n2 set 5%- + +exec-once = dbus-update-activation-environment --systemd --all +exec-once = w3bs-de autostart diff --git a/de/share/default/hypr/w3bs.lua b/de/share/default/hypr/w3bs.lua new file mode 100644 index 0000000..8259d69 --- /dev/null +++ b/de/share/default/hypr/w3bs.lua @@ -0,0 +1,164 @@ +-- W3BS DE defaults (built on Hyprland). https://w3bs.org/de +-- +-- Do not edit this file: `w3bs-de update` replaces it. Put your own settings in +-- ~/.config/w3bs-de/hypr/user.lua, which loads after this one and wins. +-- +-- Minimal and agent-friendly: +-- * one bar, one launcher, one notification center, no animations that move targets late; +-- * the Agentic Desktop Layer starts with the session (Disabled until you enable it), its tray +-- sits in the bar, and SUPER + ALT + Escape always turns agent access off; +-- * an "agent" scratch workspace (SUPER + SHIFT + A) where agents can work out of your way. + +local share = os.getenv("W3BS_DE_SHARE") or ((os.getenv("HOME") or "") .. "/.local/share/w3bs-de") +local d = share .. "/default" + +w3bs = w3bs or {} +w3bs.share = share +w3bs.terminal = os.getenv("W3BS_DE_TERMINAL") or ("foot --config=" .. d .. "/foot/foot.ini") +w3bs.launcher = "fuzzel --config=" .. d .. "/fuzzel/fuzzel.ini" +w3bs.browser = os.getenv("W3BS_DE_BROWSER") or "xdg-open https://w3bs.org" + +-- Palette (w3bs.org tokens): ink, paper, orange, sage, muted. +w3bs.colors = { + ink = "rgb(1c2b26)", + paper = "rgb(f6f4ed)", + orange = "rgb(d95231)", + sage = "rgb(e6ebdc)", + muted = "rgba(6b726899)", +} + +-- ---------------------------------------------------------------- environment + +hl.env("XDG_CURRENT_DESKTOP", "Hyprland") +hl.env("XDG_SESSION_TYPE", "wayland") +hl.env("XDG_SESSION_DESKTOP", "w3bs-de") +hl.env("W3BS_DE", "1") +hl.env("W3BS_DE_SHARE", share) +hl.env("GDK_BACKEND", "wayland,x11,*") +hl.env("QT_QPA_PLATFORM", "wayland;xcb") +hl.env("MOZ_ENABLE_WAYLAND", "1") +hl.env("ELECTRON_OZONE_PLATFORM_HINT", "wayland") +hl.env("XCURSOR_SIZE", "24") +hl.env("HYPRCURSOR_SIZE", "24") + +hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" }) + +-- ---------------------------------------------------------------- look and feel + +hl.config({ + general = { + gaps_in = 4, + gaps_out = 8, + border_size = 2, + col = { + active_border = w3bs.colors.orange, + inactive_border = w3bs.colors.muted, + }, + resize_on_border = true, + layout = "dwindle", + allow_tearing = false, + }, + decoration = { + rounding = 6, + shadow = { enabled = false }, + blur = { enabled = false }, + }, + -- Short and few: a window an agent just opened is where it says it is. + animations = { enabled = true }, + dwindle = { + preserve_split = true, + force_split = 2, + }, + input = { + kb_layout = "us", + follow_mouse = 1, + sensitivity = 0, + touchpad = { natural_scroll = true }, + }, + misc = { + disable_hyprland_logo = true, + disable_splash_rendering = true, + background_color = w3bs.colors.ink, + focus_on_activate = true, + allow_session_lock_restore = true, + }, + ecosystem = { + no_update_news = true, + no_donation_nag = true, + }, + xwayland = { force_zero_scaling = true }, +}) + +hl.curve("w3bs", { type = "bezier", points = { { 0.2, 1 }, { 0.3, 1 } } }) +hl.animation({ leaf = "global", enabled = true, speed = 4, bezier = "w3bs" }) +hl.animation({ leaf = "windows", enabled = true, speed = 2.5, bezier = "w3bs", style = "popin 92%" }) +hl.animation({ leaf = "fade", enabled = true, speed = 2.5, bezier = "w3bs" }) +hl.animation({ leaf = "workspaces", enabled = false }) + +-- ---------------------------------------------------------------- windows + +hl.window_rule({ match = { class = "^(pavucontrol|nm-connection-editor|blueman-manager|org.pulseaudio.pavucontrol)$" }, float = true }) +hl.window_rule({ match = { class = "^(hyprpolkitagent|polkit.*|gcr-prompter|pinentry.*)$" }, float = true, center = true, pin = true }) +-- The control app and the agent's own windows stay where you can see them. +hl.window_rule({ match = { title = "^(Agentic Desktop Layer.*)$" }, float = true, center = true }) + +-- ---------------------------------------------------------------- bindings + +local function bind(keys, description, action, opts) + opts = opts or {} + opts.description = description + if type(action) == "string" then + action = hl.dsp.exec_cmd(action) + end + hl.bind(keys, action, opts) +end +w3bs.bind = bind + +bind("SUPER + RETURN", "Terminal", w3bs.terminal) +bind("SUPER + SPACE", "Launcher", w3bs.launcher) +bind("SUPER + B", "Browser", w3bs.browser) +bind("SUPER + N", "Notifications", "swaync-client -t -sw") +bind("SUPER + W", "Close window", hl.dsp.window.close()) +bind("SUPER + Q", "Close window", hl.dsp.window.close()) +bind("SUPER + T", "Float / tile", hl.dsp.window.float({ action = "toggle" })) +bind("SUPER + F", "Full screen", hl.dsp.window.fullscreen({ mode = "fullscreen" })) +bind("SUPER + J", "Toggle split", hl.dsp.layout("togglesplit")) +bind("SUPER + ESCAPE", "Lock", "hyprlock --config " .. d .. "/hypr/hyprlock.conf") +bind("SUPER + SHIFT + ESCAPE", "Log out", "w3bs-de logout") +bind("PRINT", "Screenshot of a region to the clipboard", "w3bs-de screenshot region") +bind("SHIFT + PRINT", "Screenshot of the screen to ~/Pictures", "w3bs-de screenshot screen") + +for _, dir in ipairs({ { "LEFT", "l" }, { "RIGHT", "r" }, { "UP", "u" }, { "DOWN", "d" } }) do + bind("SUPER + " .. dir[1], "Focus " .. dir[2], hl.dsp.focus({ direction = dir[2] })) + bind("SUPER + SHIFT + " .. dir[1], "Swap " .. dir[2], hl.dsp.window.swap({ direction = dir[2] })) +end +for ws = 1, 9 do + local key = "code:" .. tostring(ws + 9) + bind("SUPER + " .. key, "Workspace " .. ws, hl.dsp.focus({ workspace = ws })) + bind("SUPER + SHIFT + " .. key, "Move window to workspace " .. ws, hl.dsp.window.move({ workspace = ws })) +end +bind("SUPER + TAB", "Next workspace", hl.dsp.focus({ workspace = "m+1" })) +bind("SUPER + SHIFT + TAB", "Previous workspace", hl.dsp.focus({ workspace = "m-1" })) +bind("SUPER + mouse:272", "Move window", hl.dsp.window.drag(), { mouse = true }) +bind("SUPER + mouse:273", "Resize window", hl.dsp.window.resize(), { mouse = true }) + +-- Agents. +bind("SUPER + A", "Agent control panel", "w3bs-desktop control --open") +bind("SUPER + SHIFT + A", "Agent workspace", hl.dsp.workspace.toggle_special("agent")) +bind("SUPER + ALT + A", "Move window to the agent workspace", hl.dsp.window.move({ workspace = "special:agent", follow = false })) +bind("SUPER + ALT + ESCAPE", "Turn agent access off", "w3bs-desktop disable") + +-- Media keys. +bind("XF86AudioRaiseVolume", "Volume up", "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+", { repeating = true, locked = true }) +bind("XF86AudioLowerVolume", "Volume down", "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-", { repeating = true, locked = true }) +bind("XF86AudioMute", "Mute", "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle", { locked = true }) +bind("XF86MonBrightnessUp", "Brightness up", "brightnessctl -e4 -n2 set 5%+", { repeating = true, locked = true }) +bind("XF86MonBrightnessDown", "Brightness down", "brightnessctl -e4 -n2 set 5%-", { repeating = true, locked = true }) + +-- ---------------------------------------------------------------- session + +hl.on("hyprland.start", function() + -- Services started outside the session (the agent broker) need its environment. + hl.exec_cmd("dbus-update-activation-environment --systemd --all") + hl.exec_cmd("w3bs-de autostart") +end) diff --git a/de/share/default/swaync/config.json b/de/share/default/swaync/config.json new file mode 100644 index 0000000..9ddafa9 --- /dev/null +++ b/de/share/default/swaync/config.json @@ -0,0 +1,20 @@ +{ + "$schema": "/etc/xdg/swaync/configSchema.json", + "positionX": "right", + "positionY": "top", + "layer": "overlay", + "control-center-margin-top": 8, + "control-center-margin-right": 8, + "notification-window-width": 380, + "timeout": 6, + "timeout-low": 4, + "timeout-critical": 0, + "fit-to-screen": true, + "keyboard-shortcuts": true, + "hide-on-action": true, + "widgets": ["title", "dnd", "notifications"], + "widget-config": { + "title": { "text": "Notifications", "clear-all-button": true, "button-text": "Clear" }, + "dnd": { "text": "Do not disturb" } + } +} diff --git a/de/share/default/swaync/style.css b/de/share/default/swaync/style.css new file mode 100644 index 0000000..915ebe4 --- /dev/null +++ b/de/share/default/swaync/style.css @@ -0,0 +1,29 @@ +/* W3BS DE notifications. Agent prompts (drive, human steps) are critical and stay until answered. */ +* { + font-family: Arial, Helvetica, sans-serif; +} +.notification-row .notification-background .notification, +.control-center { + background: #1c2b26; + color: #f6f4ed; + border: 1px solid #2c403a; + border-radius: 6px; +} +.notification.critical { + border: 2px solid #d95231; +} +.notification-action { + background: #2c403a; + color: #f6f4ed; + border-radius: 4px; + margin: 4px; +} +.notification-action:hover { + background: #d95231; +} +.summary { + font-weight: bold; +} +.body { + color: #dbded0; +} diff --git a/de/share/default/waybar/config.jsonc b/de/share/default/waybar/config.jsonc new file mode 100644 index 0000000..d768cb9 --- /dev/null +++ b/de/share/default/waybar/config.jsonc @@ -0,0 +1,65 @@ +// W3BS DE bar (waybar). https://w3bs.org/de +{ + "layer": "top", + "position": "top", + "height": 30, + "spacing": 2, + "modules-left": ["custom/w3bs", "hyprland/workspaces"], + "modules-center": ["hyprland/window"], + "modules-right": ["custom/agents", "tray", "pulseaudio", "network", "battery", "clock", "custom/notifications"], + + "custom/w3bs": { + "format": "w3bs.", + "tooltip-format": "Launcher (SUPER + SPACE)", + "on-click": "w3bs-de launcher" + }, + "hyprland/workspaces": { + "format": "{name}", + "show-special": true, + "special-visible-only": true, + "on-click": "activate" + }, + "hyprland/window": { + "max-length": 80, + "separate-outputs": true + }, + // Agent access at a glance: off, on, or which agent is driving. Click: the control app. + "custom/agents": { + "exec": "w3bs-de agents-status", + "return-type": "json", + "interval": 2, + "on-click": "w3bs-desktop control --open", + "on-click-right": "w3bs-desktop disable" + }, + "tray": { "icon-size": 16, "spacing": 8 }, + "pulseaudio": { + "format": "vol {volume}%", + "format-muted": "muted", + "on-click": "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle", + "on-click-right": "pavucontrol" + }, + "network": { + "format-wifi": "{essid}", + "format-ethernet": "wired", + "format-disconnected": "offline", + "tooltip-format": "{ifname} {ipaddr}" + }, + "battery": { + "format": "bat {capacity}%", + "format-charging": "chg {capacity}%", + "states": { "warning": 20, "critical": 10 } + }, + "clock": { + "format": "{:%a %d %b %H:%M}", + "tooltip-format": "{:%Y-%m-%d}" + }, + "custom/notifications": { + "exec": "swaync-client -swb", + "return-type": "json", + "format": "{icon}", + "format-icons": { "notification": "notes •", "none": "notes", "dnd-notification": "dnd •", "dnd-none": "dnd" }, + "on-click": "swaync-client -t -sw", + "on-click-right": "swaync-client -d -sw", + "escape": true + } +} diff --git a/de/share/default/waybar/style.css b/de/share/default/waybar/style.css new file mode 100644 index 0000000..e1b5112 --- /dev/null +++ b/de/share/default/waybar/style.css @@ -0,0 +1,62 @@ +/* W3BS DE bar. Palette from w3bs.org: ink, paper, orange, sage. */ +* { + font-family: Arial, Helvetica, sans-serif; + font-size: 13px; + min-height: 0; + border: none; + border-radius: 0; +} +window#waybar { + background: #1c2b26; + color: #f6f4ed; + border-bottom: 1px solid #2c403a; +} +#custom-w3bs { + font-weight: 800; + letter-spacing: -1px; + font-size: 15px; + padding: 0 12px 0 14px; +} +#workspaces button { + color: #a8ae9f; + padding: 0 8px; + background: transparent; +} +#workspaces button.active { + color: #f6f4ed; + box-shadow: inset 0 -2px #d95231; +} +#workspaces button.special { + color: #d95231; +} +#window { + color: #dbded0; +} +#custom-agents, #tray, #pulseaudio, #network, #battery, #clock, #custom-notifications { + padding: 0 10px; + color: #dbded0; +} +#custom-agents.off { + color: #6b7268; +} +#custom-agents.on { + color: #f6f4ed; +} +#custom-agents.driving { + color: #1c2b26; + background: #d95231; +} +#custom-agents.waiting { + color: #1c2b26; + background: #e6ebdc; +} +#battery.warning { + color: #d9a441; +} +#battery.critical { + color: #d95231; +} +tooltip { + background: #1c2b26; + border: 1px solid #d95231; +} diff --git a/de/share/wallpaper/w3bs-dark.png b/de/share/wallpaper/w3bs-dark.png new file mode 100644 index 0000000..e3e76a4 Binary files /dev/null and b/de/share/wallpaper/w3bs-dark.png differ diff --git a/de/share/wallpaper/w3bs-light.png b/de/share/wallpaper/w3bs-light.png new file mode 100644 index 0000000..0da3460 Binary files /dev/null and b/de/share/wallpaper/w3bs-light.png differ diff --git a/de/tools/wallpaper.py b/de/tools/wallpaper.py new file mode 100755 index 0000000..92abda5 --- /dev/null +++ b/de/tools/wallpaper.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Draw the W3BS DE wallpapers: the w3bs mark (public/mark.svg) as a quiet outline on ink. + + python3 de/tools/wallpaper.py de/share/wallpaper + +Writes w3bs-dark.png (3840x2160) and w3bs-light.png. Needs Pillow. +""" +import math +import sys +from pathlib import Path + +from PIL import Image, ImageDraw + +INK = (28, 43, 38) # a shade under --ink #23352f, so the bar and borders read on it +PAPER = (246, 244, 237) # --paper +ORANGE = (217, 82, 49) # --orange +W, H = 3840, 2160 +SS = 3 # supersampling for the mark + + +def ellipse_points(cx, cy, rx, ry, deg, n=720): + a = math.radians(deg) + pts = [] + for i in range(n + 1): + t = 2 * math.pi * i / n + x, y = rx * math.cos(t), ry * math.sin(t) + pts.append((cx + x * math.cos(a) - y * math.sin(a), cy + x * math.sin(a) + y * math.cos(a))) + return pts + + +def mark(size, stroke_rgba, dot_rgba, width): + """The mark's lines (two crossed ellipses, the vertical, the dot), without its tile.""" + s = size * SS + img = Image.new("RGBA", (s, s), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + k = s / 64 + w = max(1, round(width * SS)) + for deg in (-30, 30): + d.line(ellipse_points(32 * k, 32 * k, 22 * k, 11 * k, deg), fill=stroke_rgba, width=w, joint="curve") + d.line([(32 * k, 10 * k), (32 * k, 54 * k)], fill=stroke_rgba, width=w) + r = 5 * k + d.ellipse([32 * k - r, 32 * k - r, 32 * k + r, 32 * k + r], fill=dot_rgba) + return img.resize((size, size), Image.LANCZOS) + + +def wallpaper(bg, line, dot, path): + img = Image.new("RGB", (W, H), bg) + size = 1500 + m = mark(size, line, dot, 3.2 * size / 64 / 6) + img.paste(m, ((W - size) // 2, (H - size) // 2), m) + img.save(path, optimize=True) + + +def main(out): + out = Path(out) + out.mkdir(parents=True, exist_ok=True) + wallpaper(INK, PAPER + (22,), ORANGE + (200,), out / "w3bs-dark.png") + wallpaper(PAPER, (35, 53, 47, 26), ORANGE + (210,), out / "w3bs-light.png") + + +if __name__ == "__main__": + main(sys.argv[1] if len(sys.argv) > 1 else "de/share/wallpaper") diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 479bfdc..38502fe 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "adl-broker" -version = "0.7.0" +version = "0.8.0" dependencies = [ "adl-schema", "base64", @@ -19,7 +19,7 @@ dependencies = [ [[package]] name = "adl-linux" -version = "0.7.0" +version = "0.8.0" dependencies = [ "adl-broker", "adl-schema", @@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "adl-macos" -version = "0.7.0" +version = "0.8.0" dependencies = [ "adl-broker", "adl-schema", @@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "adl-schema" -version = "0.7.0" +version = "0.8.0" dependencies = [ "serde", "serde_json", @@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "adl-windows" -version = "0.7.0" +version = "0.8.0" dependencies = [ "adl-broker", "adl-schema", @@ -1303,7 +1303,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "w3bs-desktop" -version = "0.7.0" +version = "0.8.0" dependencies = [ "adl-broker", "adl-linux", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index cd6cdd0..3e007f5 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/adl-schema", "crates/adl-broker", "crates/adl-linux", "crates/adl-macos", "crates/adl-windows", "crates/w3bs-desktop"] [workspace.package] -version = "0.7.0" +version = "0.8.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/profullstack/w3bs.org" diff --git a/desktop/README.md b/desktop/README.md index 082964a..8934202 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -153,6 +153,44 @@ $B doctor # gnome_shell_extension: ok once it is loaded Without it the portal backend still types and clicks, but window listing is unsupported and targeted input is refused. +## Hyprland (and W3BS DE) + +Hyprland's portal (xdg-desktop-portal-hyprland) has no RemoteDesktop interface, so the portal +backend cannot type or click there. `--adapter auto` therefore picks a Hyprland backend +(`linux-hyprland`) whenever `HYPRLAND_INSTANCE_SIGNATURE` points at a live Hyprland IPC socket: + +- **Layout and windows:** Hyprland's IPC (`j/monitors`, `j/clients`, `j/cursorpos`). Hyprland + serves one request per connection, synchronously, so each request opens, writes, reads and + closes. Windows are ordered topmost first (special workspace, fullscreen, floating and pinned, + then tiled) for occlusion checks. Monitor hotplug and config reloads on `.socket2.sock` mark + captures stale. +- **Input:** the backend's own Wayland connection with `zwlr_virtual_pointer_v1` and + `zwp_virtual_keyboard_v1`, with a keymap it generates. Hyprland resolves its key bindings through + the seat's keymap, not the virtual keyboard's, so keycodes matter: every key a US keyboard has + sits at its real position with both levels (Shift goes through the modifier state, as with real + keys, and SUPER+1 still reaches the workspace binding); other characters (é, ✓, 日) go on keycodes + the seat's keymap leaves empty (read from the compositor; 14 on a US map), so no binding can + swallow them, and text needing more is typed in parts. (A wtype-style keymap that numbers keysyms + from keycode 9 loses `c`, `d` and `e`: on a US map those codes are Mute and Volume.) Closing the + connection (Stop, Disable) destroys both devices, which releases anything held. +- **Captures:** `grim` (wlr-screencopy). +- **Interlocks:** input is refused while hyprlock or swaylock runs, or while an authentication + dialog (polkit agent, pinentry, GCR prompter, ssh-askpass) is mapped. +- **Takeover:** the pointer moving away from where the agent left it is physical input, which + disables access. Hyprland cannot tell a person's keys from virtual ones, so keyboard takeover is + not detected; use the mouse or the emergency stop. +- **Native control:** the tray (StatusNotifierItem) shows in waybar. W3BS DE (w3bs.org/de) also + puts an agent indicator in its bar (`w3bs-desktop status --waybar`) and binds SUPER+ALT+Escape to + `w3bs-desktop disable`. + +Verified on the Hyprland 0.56.2 rig (`tests/rig/hyprland.sh`, then `tests/rig/hyprland-e2e.sh`), +with W3BS DE's bindings loaded: backend auto-detected, windows and layout, a 1920x1080 capture, +every US key on both levels plus accents and 20 CJK characters typed exactly, absolute pointer +motion, a click, and SUPER+2 switching workspaces. + +If Hyprland's permission system is on (`ecosystem.enforce_permissions`), allow `grim` the +`screencopy` permission. + ## Drive prompt Before an agent takes the pointer and keyboard, the person at the desktop is asked: diff --git a/desktop/crates/adl-linux/Cargo.toml b/desktop/crates/adl-linux/Cargo.toml index 1324639..60b20ac 100644 --- a/desktop/crates/adl-linux/Cargo.toml +++ b/desktop/crates/adl-linux/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "adl-linux" -description = "Agentic Desktop Layer: Linux adapters (Vela command socket, key mapping; portals and wlroots virtual input to follow)" +description = "Agentic Desktop Layer: Linux adapters (XDG portals with KWin/GNOME Shell, Hyprland with wlroots virtual input, Vela)" version.workspace = true edition.workspace = true license.workspace = true diff --git a/desktop/crates/adl-linux/src/hyprland.rs b/desktop/crates/adl-linux/src/hyprland.rs new file mode 100644 index 0000000..722ad5d --- /dev/null +++ b/desktop/crates/adl-linux/src/hyprland.rs @@ -0,0 +1,961 @@ +//! Hyprland adapter: the W3BS DE session (w3bs.org/de) and any other Hyprland desktop. +//! +//! Hyprland's portal (xdg-desktop-portal-hyprland) has no RemoteDesktop interface, so the portal +//! backend cannot inject input here. Hyprland instead implements the wlroots virtual input +//! protocols, which this adapter speaks directly over its own Wayland connection: +//! - `zwlr_virtual_pointer_v1` for absolute/relative motion, buttons and wheel steps; +//! - `zwp_virtual_keyboard_v1` with a keymap this adapter generates (like `wtype`): every keysym +//! it needs gets its own keycode, so any Unicode character types exactly, on any layout, and +//! nothing is ever silently dropped. Modifier state is sent explicitly with each change. +//! +//! Everything else comes from Hyprland's IPC (`$XDG_RUNTIME_DIR/hypr//.socket.sock`, +//! one request per connection, closed straight away because Hyprland serves it synchronously): +//! `j/monitors` for the layout, `j/clients` for windows, focus and stacking, `j/cursorpos` for +//! the pointer. `.socket2.sock` events mark the layout stale. Captures go through `grim` +//! (wlr-screencopy). +//! +//! Interlocks: input is refused while hyprlock runs or an authentication dialog (polkit agent, +//! pinentry, GCR prompter, ssh-askpass) is mapped. The person moving the pointer away from where +//! the agent left it is reported as physical input (a takeover); keyboard takeover is not +//! detectable on this backend. + +use crate::keymap; +use crate::wl::{Args, Wire}; +use adl_broker::{Adapter, InputEvent, SessionEvent}; +use adl_schema::*; +use serde_json::Value; +use std::collections::BTreeSet; +use std::io::{BufRead, BufReader, Read, Write}; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +const BACKEND: &str = "linux-hyprland"; + +// ---------------------------------------------------------------- IPC + +/// Hyprland's request socket for this session. +#[derive(Clone, Debug)] +pub struct HyprIpc { + dir: PathBuf, +} + +impl HyprIpc { + /// From `HYPRLAND_INSTANCE_SIGNATURE` (set for every process in a Hyprland session). + pub fn from_env() -> Option { + let sig = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").ok().filter(|s| !s.is_empty() && !s.contains('/'))?; + let runtime = std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from)?; + let dir = runtime.join("hypr").join(sig); + dir.join(".socket.sock").exists().then_some(Self { dir }) + } + + pub fn at(dir: PathBuf) -> Self { + Self { dir } + } + + /// One request, one connection: write, then read the whole reply. + pub fn request(&self, cmd: &str) -> Result { + let path = self.dir.join(".socket.sock"); + let mut s = UnixStream::connect(&path).map_err(|e| AdlError::new(ErrorCode::Blocked, format!("Hyprland IPC {}: {e}", path.display())))?; + let t = Some(Duration::from_secs(2)); + s.set_read_timeout(t).and_then(|_| s.set_write_timeout(t)).map_err(|e| AdlError::internal(e.to_string()))?; + s.write_all(cmd.as_bytes()).map_err(|e| AdlError::internal(format!("Hyprland IPC write: {e}")))?; + let mut out = String::new(); + s.read_to_string(&mut out).map_err(|e| AdlError::internal(format!("Hyprland IPC read: {e}")))?; + Ok(out) + } + + pub fn json(&self, cmd: &str) -> Result { + let r = self.request(&format!("j/{cmd}"))?; + serde_json::from_str(&r).map_err(|e| AdlError::internal(format!("unexpected Hyprland reply to {cmd}: {e}: {}", r.chars().take(120).collect::()))) + } + + fn events_socket(&self) -> PathBuf { + self.dir.join(".socket2.sock") + } +} + +// ---------------------------------------------------------------- state from IPC + +#[derive(Clone, Debug, PartialEq)] +pub struct Monitor { + pub id: i64, + pub name: String, + pub x: f64, + pub y: f64, + /// Mode size in physical pixels, before rotation. + pub width: f64, + pub height: f64, + pub scale: f64, + pub transform: i64, + pub active_ws: i64, + pub special_ws: i64, + pub focused: bool, +} + +fn num(v: &Value, k: &str) -> f64 { + v.get(k).and_then(Value::as_f64).unwrap_or(0.0) +} + +fn int(v: &Value, k: &str) -> i64 { + v.get(k).and_then(|x| x.as_i64().or_else(|| x.as_bool().map(i64::from))).unwrap_or(0) +} + +fn text(v: &Value, k: &str) -> String { + v.get(k).and_then(Value::as_str).unwrap_or_default().to_string() +} + +fn flag(v: &Value, k: &str) -> bool { + v.get(k).and_then(|x| x.as_bool().or_else(|| x.as_i64().map(|n| n != 0))).unwrap_or(false) +} + +pub fn parse_monitors(v: &Value) -> Vec { + v.as_array() + .into_iter() + .flatten() + .filter(|m| !flag(m, "disabled")) + .map(|m| Monitor { + id: int(m, "id"), + name: text(m, "name"), + x: num(m, "x"), + y: num(m, "y"), + width: num(m, "width"), + height: num(m, "height"), + scale: Some(num(m, "scale")).filter(|s| *s > 0.0).unwrap_or(1.0), + transform: int(m, "transform"), + active_ws: m.get("activeWorkspace").map_or(0, |w| int(w, "id")), + special_ws: m.get("specialWorkspace").map_or(0, |w| int(w, "id")), + focused: flag(m, "focused"), + }) + .collect() +} + +impl Monitor { + /// Hyprland transforms 1/3/5/7 turn the output a quarter. + fn quarter_turn(&self) -> bool { + self.transform % 2 == 1 + } + + pub fn logical(&self) -> Rect { + let (w, h) = if self.quarter_turn() { (self.height, self.width) } else { (self.width, self.height) }; + Rect { x: self.x, y: self.y, width: (w / self.scale).round(), height: (h / self.scale).round() } + } + + pub fn display(&self) -> DisplayInfo { + DisplayInfo { + id: self.name.clone(), + name: self.name.clone(), + logical: self.logical(), + scale: self.scale, + rotation: match self.transform % 4 { + 1 => Rotation::Rotate90, + 2 => Rotation::Rotate180, + 3 => Rotation::Rotate270, + _ => Rotation::Normal, + }, + primary: self.focused, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Client { + pub address: String, + pub class: String, + pub title: String, + pub bounds: Rect, + pub workspace: i64, + pub monitor: i64, + pub floating: bool, + pub pinned: bool, + pub fullscreen: bool, + /// 0 is the focused window; higher was focused longer ago. + pub focus_history: i64, + pub mapped: bool, + pub hidden: bool, +} + +pub fn parse_clients(v: &Value) -> Vec { + v.as_array() + .into_iter() + .flatten() + .map(|c| { + let pair = |k: &str| c.get(k).and_then(Value::as_array).map_or((0.0, 0.0), |a| (a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0))); + let ((x, y), (w, h)) = (pair("at"), pair("size")); + Client { + address: text(c, "address"), + class: Some(text(c, "class")).filter(|s| !s.is_empty()).unwrap_or_else(|| text(c, "initialClass")), + title: text(c, "title"), + bounds: Rect { x, y, width: w, height: h }, + workspace: c.get("workspace").map_or(0, |w| int(w, "id")), + monitor: int(c, "monitor"), + floating: flag(c, "floating"), + pinned: flag(c, "pinned"), + fullscreen: flag(c, "fullscreen"), + focus_history: c.get("focusHistoryID").and_then(Value::as_i64).unwrap_or(i64::MAX), + mapped: c.get("mapped").and_then(Value::as_bool).unwrap_or(true), + hidden: flag(c, "hidden"), + } + }) + .collect() +} + +/// Windows on screen (mapped, not hidden, on a monitor's active or special workspace, or pinned), +/// topmost first: a special workspace over everything, then fullscreen, then floating and pinned +/// (most recently focused first), then tiled windows, which do not overlap each other. +pub fn visible<'a>(clients: &'a [Client], monitors: &[Monitor]) -> Vec<&'a Client> { + let shown = |c: &Client| { + c.mapped + && !c.hidden + && (c.pinned || monitors.iter().any(|m| c.workspace == m.active_ws || (m.special_ws != 0 && c.workspace == m.special_ws))) + }; + let special = |c: &Client| monitors.iter().any(|m| m.special_ws != 0 && c.workspace == m.special_ws); + let mut v: Vec<&Client> = clients.iter().filter(|c| shown(c)).collect(); + v.sort_by_key(|c| (!special(c), !c.fullscreen, !(c.floating || c.pinned), c.focus_history)); + v +} + +fn window_info(c: &Client, monitors: &[Monitor]) -> WindowInfo { + WindowInfo { + id: c.address.clone(), + app_id: c.class.clone(), + title: c.title.clone(), + bounds: Some(c.bounds), + display: monitors.iter().find(|m| m.id == c.monitor).map(|m| m.name.clone()), + focused: c.focus_history == 0, + } +} + +/// Window classes of authentication dialogs: synthetic input must never reach them. +const AUTH_CLASSES: &[&str] = &["polkit", "pinentry", "gcr-prompter", "ssh-askpass", "askpass", "keyring"]; + +pub fn auth_dialog(clients: &[Client]) -> Option<&Client> { + clients.iter().find(|c| c.mapped && !c.hidden && AUTH_CLASSES.iter().any(|a| c.class.to_ascii_lowercase().contains(a))) +} + +/// Whether a screen locker is running (hyprlock, swaylock). +fn locker_running() -> Option { + let dir = std::fs::read_dir("/proc").ok()?; + for e in dir.flatten() { + if !e.file_name().to_string_lossy().bytes().all(|b| b.is_ascii_digit()) { + continue; + } + if let Ok(comm) = std::fs::read_to_string(e.path().join("comm")) { + let comm = comm.trim(); + if comm == "hyprlock" || comm == "swaylock" { + return Some(comm.to_string()); + } + } + } + None +} + +// ---------------------------------------------------------------- generated keymap + +const SHIFT_L: u32 = 0xffe1; +const CONTROL_L: u32 = 0xffe3; +const ALT_L: u32 = 0xffe9; +const SUPER_L: u32 = 0xffeb; + +/// Evdev codes with no symbol on a US (evdev, pc105) keymap, used when the compositor's own keymap +/// cannot be read. Measured from xkeyboard-config with `xkbcli compile-keymap --layout us`. +pub const FREE_US_CODES: &[u32] = &[89, 95, 112, 124, 141, 146, 160, 170, 175, 176, 211, 214, 222, 240]; + +/// The virtual keyboard's keymap. Hyprland resolves its key bindings through the *seat's* keymap, +/// not the virtual keyboard's, so a keycode that is a media key there (evdev 113-115 are Mute and +/// Volume on a US map) fires that binding whatever keysym our keymap gives it. So: +/// - every key a US keyboard has sits at its real position with both levels (a/A, 1/!), and Shift +/// goes through the modifier state, as with a physical keyboard: bindings see what they would +/// see from real keys (SUPER+1 is still workspace 1); +/// - anything else (é, ✓, 日) goes on a keycode that has no symbol at all in the seat's keymap, +/// so no binding can match it. There are few of those; text that needs more is typed in parts. +#[derive(Clone, Debug, PartialEq)] +pub struct KeyTable { + /// evdev code -> (base keysym, shifted keysym or 0) + fixed: std::collections::BTreeMap, + free: Vec, + /// (evdev code, keysym) currently assigned from `free` + extra: Vec<(u32, u32)>, +} + +impl KeyTable { + pub fn new(free: &[u32]) -> Self { + let mut fixed = std::collections::BTreeMap::new(); + for (code, sym) in [(42, SHIFT_L), (29, CONTROL_L), (56, ALT_L), (125, SUPER_L)] { + fixed.insert(code, (sym, 0)); + } + for n in adl_schema::keys::NAMED_KEYS { + if let (Some(code), Some(sym)) = (keymap::key_code(&Key::Named(n)), keymap::keysym(&Key::Named(n))) { + fixed.entry(code).or_insert((sym, 0)); + } + } + for c in ' '..='~' { + if let Some((code, shift)) = keymap::char_code(c) { + let e = fixed.entry(code).or_insert((0, 0)); + if shift { e.1 = keymap::char_keysym(c) } else { e.0 = keymap::char_keysym(c) } + } + } + let free = free.iter().copied().filter(|c| (1..=247).contains(c) && !fixed.contains_key(c)).collect(); + Self { fixed, free, extra: vec![] } + } + + /// Where `sym` is now: (evdev code, needs Shift). + pub fn lookup(&self, sym: u32) -> Option<(u32, bool)> { + for (code, (base, shifted)) in &self.fixed { + if *base == sym { + return Some((*code, false)); + } + if *shifted == sym && sym != 0 { + return Some((*code, true)); + } + } + self.extra.iter().find(|(_, s)| *s == sym).map(|(c, _)| (*c, false)) + } + + pub fn capacity(&self) -> usize { + self.free.len() + } + + /// Make room for `syms` (at most `capacity()` of them need a free code). Returns whether the + /// keymap changed and must be uploaded again. + pub fn assign(&mut self, syms: &[u32]) -> bool { + let missing: Vec = syms.iter().copied().filter(|s| self.lookup(*s).is_none()).collect(); + if missing.is_empty() { + return false; + } + // Keep extras still wanted; drop the rest to make room. + self.extra.retain(|(_, s)| syms.contains(s)); + for sym in missing { + if let Some(code) = self.free.iter().copied().find(|c| !self.extra.iter().any(|(e, _)| e == c)) { + self.extra.push((code, sym)); + } + } + true + } + + /// XKB v1 keymap text. Keysyms are written as hex; two-level keys get their types from + /// the `complete` set (ALPHABETIC for letters, TWO_LEVEL otherwise). + pub fn keymap(&self) -> String { + let mut keys: Vec<(u32, u32, u32)> = self.fixed.iter().map(|(c, (b, s))| (*c, *b, *s)).collect(); + keys.extend(self.extra.iter().map(|(c, s)| (*c, *s, 0))); + keys.sort(); + let mut k = String::from("xkb_keymap {\nxkb_keycodes \"w3bs\" {\n minimum = 8;\n maximum = 255;\n"); + for (code, _, _) in &keys { + k += &format!(" = {};\n", code + 8); + } + k += "};\nxkb_types \"w3bs\" { include \"complete\" };\nxkb_compatibility \"w3bs\" { include \"complete\" };\nxkb_symbols \"w3bs\" {\n"; + for (code, base, shifted) in &keys { + if *shifted != 0 { + k += &format!(" key {{ [ 0x{base:08x}, 0x{shifted:08x} ] }};\n"); + } else { + k += &format!(" key {{ [ 0x{base:08x} ] }};\n"); + } + } + k += " modifier_map Shift { };\n modifier_map Control { };\n modifier_map Mod1 { };\n modifier_map Mod4 { };\n};\n};\n"; + k + } +} + +/// Keycodes the seat's keymap leaves empty, read from the compositor (before our own virtual +/// keyboard exists, so it is the real keyboard's map); the US list when that is not possible. +fn free_codes() -> Vec { + let live = crate::xkb::wayland_keymap() + .ok() + .filter(|t| !String::from_utf8_lossy(t).contains("xkb_keycodes \"w3bs\"")) + .and_then(|t| crate::xkb::empty_keycodes(&t).ok()) + .map(|v| v.into_iter().map(|kc| kc - 8).filter(|c| (1..=247).contains(c)).collect::>()) + .filter(|v| v.len() >= 4); + live.unwrap_or_else(|| FREE_US_CODES.to_vec()) +} + +/// XKB modifier mask bits for the standard modifier map. +pub fn modifier_mask(m: Modifier) -> u32 { + match m { + Modifier::Shift => 1, + Modifier::Ctrl | Modifier::Primary => 4, + Modifier::Alt => 8, + Modifier::Super => 64, + Modifier::Command | Modifier::Option => 0, + } +} + +fn modifier_sym(m: Modifier) -> Option { + Some(match m { + Modifier::Shift => SHIFT_L, + Modifier::Ctrl | Modifier::Primary => CONTROL_L, + Modifier::Alt => ALT_L, + Modifier::Super => SUPER_L, + Modifier::Command | Modifier::Option => return None, + }) +} + +// ---------------------------------------------------------------- virtual input + +const DISPLAY: u32 = 1; +const REGISTRY: u32 = 2; + +/// One Wayland connection holding a virtual pointer and a virtual keyboard. Dropping it closes the +/// connection, which destroys both devices (and releases anything held). +struct VirtualInput { + w: Wire, + next: u32, + pointer: u32, + keyboard: u32, + keys: KeyTable, + mods: u32, + started: Instant, +} + +impl VirtualInput { + fn open() -> std::result::Result { + let free = free_codes(); + let mut w = Wire::connect(Duration::from_secs(2))?; + w.send(DISPLAY, 1, &Args::new().u32(REGISTRY))?; // get_registry + w.send(DISPLAY, 0, &Args::new().u32(3))?; // sync + let (mut seat, mut vp, mut vk) = (None, None, None); + w.until_done(3, |obj, op, a, _| { + if obj == REGISTRY && op == 0 { + let (name, iface, ver) = (a.u32()?, a.string()?, a.u32()?); + match iface.as_str() { + "wl_seat" if seat.is_none() => seat = Some((name, ver)), + "zwlr_virtual_pointer_manager_v1" => vp = Some((name, ver)), + "zwp_virtual_keyboard_manager_v1" => vk = Some((name, ver)), + _ => {} + } + } + Ok(()) + })?; + let seat = seat.ok_or("the compositor offers no wl_seat")?; + let vp = vp.ok_or("the compositor does not offer zwlr_virtual_pointer_manager_v1")?; + let vk = vk.ok_or("the compositor does not offer zwp_virtual_keyboard_manager_v1")?; + let (seat_id, vpm, vkm, pointer, keyboard) = (4, 5, 6, 7, 8); + let bind = |w: &mut Wire, (name, ver): (u32, u32), iface: &str, max: u32, id: u32| w.send(REGISTRY, 0, &Args::new().u32(name).string(iface).u32(ver.min(max)).u32(id)); + bind(&mut w, seat, "wl_seat", 1, seat_id)?; + bind(&mut w, vp, "zwlr_virtual_pointer_manager_v1", 1, vpm)?; + bind(&mut w, vk, "zwp_virtual_keyboard_manager_v1", 1, vkm)?; + w.send(vpm, 0, &Args::new().u32(seat_id).u32(pointer))?; // create_virtual_pointer + w.send(vkm, 0, &Args::new().u32(seat_id).u32(keyboard))?; // create_virtual_keyboard + let mut vi = Self { w, next: 9, pointer, keyboard, keys: KeyTable::new(&free), mods: 0, started: Instant::now() }; + vi.upload_keymap()?; + vi.roundtrip()?; + Ok(vi) + } + + fn time(&self) -> u32 { + self.started.elapsed().as_millis() as u32 + } + + /// wl_display.sync and wait: delivers everything sent so far and surfaces protocol errors. + fn roundtrip(&mut self) -> std::result::Result<(), String> { + let cb = self.next; + self.next += 1; + self.w.send(DISPLAY, 0, &Args::new().u32(cb))?; + self.w.until_done(cb, |_, _, _, fds| { + fds.clear(); + Ok(()) + }) + } + + fn upload_keymap(&mut self) -> std::result::Result<(), String> { + let mut text = self.keys.keymap().into_bytes(); + text.push(0); + // SAFETY: memfd_create with a valid NUL-terminated name; the fd is owned immediately. + let fd = unsafe { libc::memfd_create(c"w3bs-keymap".as_ptr(), libc::MFD_CLOEXEC) }; + if fd < 0 { + return Err(format!("memfd_create: {}", std::io::Error::last_os_error())); + } + // SAFETY: fd was just returned by memfd_create and is not owned elsewhere. + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + let mut f = std::fs::File::from(fd); + f.write_all(&text).map_err(|e| format!("writing the keymap: {e}"))?; + // keymap(format XKB_V1, fd, size) + self.w.send_fd(self.keyboard, 0, &Args::new().u32(1).u32(text.len() as u32), f.as_raw_fd()) + } + + fn frame(&mut self) -> std::result::Result<(), String> { + self.w.send(self.pointer, 4, &Args::new()) + } + + fn fixed(v: f64) -> u32 { + (v * 256.0).round() as i32 as u32 + } + + fn motion_absolute(&mut self, x: f64, y: f64, layout: Rect) -> std::result::Result<(), String> { + // 1/8 px resolution over the whole layout box (no output bound: Hyprland maps the extent + // onto every monitor's union). + let k = 8.0; + let (ex, ey) = ((layout.width * k).round().max(1.0), (layout.height * k).round().max(1.0)); + let ux = ((x - layout.x) * k).round().clamp(0.0, ex - 1.0); + let uy = ((y - layout.y) * k).round().clamp(0.0, ey - 1.0); + let t = self.time(); + self.w.send(self.pointer, 1, &Args::new().u32(t).u32(ux as u32).u32(uy as u32).u32(ex as u32).u32(ey as u32))?; + self.frame() + } + + fn motion_relative(&mut self, dx: f64, dy: f64) -> std::result::Result<(), String> { + let t = self.time(); + self.w.send(self.pointer, 0, &Args::new().u32(t).u32(Self::fixed(dx)).u32(Self::fixed(dy)))?; + self.frame() + } + + fn button(&mut self, b: Button, pressed: bool) -> std::result::Result<(), String> { + let t = self.time(); + self.w.send(self.pointer, 2, &Args::new().u32(t).u32(keymap::button_code(b)).u32(pressed as u32))?; + self.frame() + } + + fn scroll(&mut self, axis: Axis, steps: i32) -> std::result::Result<(), String> { + let t = self.time(); + let a = match axis { + Axis::Vertical => 0, + Axis::Horizontal => 1, + }; + self.w.send(self.pointer, 5, &Args::new().u32(0))?; // axis_source(wheel) + self.w.send(self.pointer, 7, &Args::new().u32(t).u32(a).u32(Self::fixed(15.0 * steps as f64)).u32(steps as u32))?; // axis_discrete + self.frame() + } + + fn ensure(&mut self, syms: &[u32]) -> std::result::Result<(), String> { + if self.keys.assign(syms) { + self.upload_keymap()?; + self.roundtrip()?; + } + Ok(()) + } + + fn send_mods(&mut self, mods: u32) -> std::result::Result<(), String> { + self.w.send(self.keyboard, 2, &Args::new().u32(mods).u32(0).u32(0).u32(0)) + } + + fn raw_key(&mut self, code: u32, pressed: bool) -> std::result::Result<(), String> { + let t = self.time(); + self.w.send(self.keyboard, 1, &Args::new().u32(t).u32(code).u32(pressed as u32)) + } + + /// Press or release the key that produces `sym`. A shifted symbol pressed without Shift held + /// gets Shift for that key only. + fn key(&mut self, sym: u32, pressed: bool) -> std::result::Result<(), String> { + self.ensure(&[sym])?; + let (code, shift) = self.keys.lookup(sym).ok_or_else(|| format!("no keycode left for keysym 0x{sym:x}"))?; + let add = shift && self.mods & 1 == 0; + if add { + self.send_mods(self.mods | 1)?; + } + self.raw_key(code, pressed)?; + if add { + self.send_mods(self.mods)?; + } + Ok(()) + } + + fn modifier(&mut self, m: Modifier, pressed: bool) -> std::result::Result<(), String> { + let sym = modifier_sym(m).ok_or_else(|| format!("{m:?} does not exist on Linux"))?; + let (code, _) = self.keys.lookup(sym).ok_or("modifier missing from the keymap")?; + self.raw_key(code, pressed)?; + let bit = modifier_mask(m); + if pressed { self.mods |= bit } else { self.mods &= !bit } + self.send_mods(self.mods) + } + + /// Type text: a press and release per character, in parts when it needs more symbols + /// without a US key than there are free keycodes. + fn text(&mut self, s: &str) -> std::result::Result<(), String> { + let syms: Vec = s.chars().map(keymap::char_keysym).collect(); + let cap = self.keys.capacity().max(1); + let mut i = 0; + while i < syms.len() { + let mut extras: Vec = vec![]; + let mut j = i; + while j < syms.len() { + let s = syms[j]; + let fixed = self.keys.lookup(s).is_some_and(|_| !self.keys.extra.iter().any(|(_, e)| *e == s)); + if !fixed && !extras.contains(&s) { + if extras.len() == cap { + break; + } + extras.push(s); + } + j += 1; + } + self.ensure(&extras)?; + for &sym in &syms[i..j] { + self.key(sym, true)?; + self.key(sym, false)?; + } + i = j; + } + Ok(()) + } +} + +// ---------------------------------------------------------------- adapter + +#[derive(Default)] +struct Shared { + input: Option, + /// Where this adapter last put the pointer; anything else means a person moved it. + expected: Option<(f64, f64)>, + pending: Vec, + generation: u64, +} + +pub struct HyprlandAdapter { + ipc: HyprIpc, + grim: Option, + s: Arc>, +} + +fn which(cmd: &str) -> Option { + std::env::var_os("PATH").and_then(|p| std::env::split_paths(&p).map(|d| d.join(cmd)).find(|f| f.is_file())) +} + +impl HyprlandAdapter { + /// Running inside a Hyprland session with its IPC socket present. + pub fn detect() -> bool { + HyprIpc::from_env().is_some() + } + + pub fn new(ipc: HyprIpc) -> Self { + let s = Arc::new(Mutex::new(Shared { generation: 1, ..Default::default() })); + Self::watch_events(&ipc, s.clone()); + Self { ipc, grim: which("grim"), s } + } + + pub fn from_env() -> Result { + HyprIpc::from_env().map(Self::new).ok_or_else(|| AdlError::new(ErrorCode::Blocked, "no Hyprland session (HYPRLAND_INSTANCE_SIGNATURE and its IPC socket are missing)")) + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Shared> { + self.s.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Monitor and config changes arrive on `.socket2.sock` as `event>>data` lines. + fn watch_events(ipc: &HyprIpc, s: Arc>) { + let path = ipc.events_socket(); + std::thread::spawn(move || loop { + if let Ok(sock) = UnixStream::connect(&path) { + for line in BufReader::new(sock).lines() { + let Ok(line) = line else { break }; + let ev = line.split(">>").next().unwrap_or(""); + if matches!(ev, "monitoradded" | "monitoraddedv2" | "monitorremoved" | "monitorremovedv2" | "configreloaded") { + let mut g = s.lock().unwrap_or_else(|e| e.into_inner()); + g.generation += 1; + g.pending.push(SessionEvent::LayoutChanged); + } + } + } + std::thread::sleep(Duration::from_secs(2)); + }); + } + + fn monitors(&self) -> Result> { + Ok(parse_monitors(&self.ipc.json("monitors")?)) + } + + fn clients(&self) -> Result> { + Ok(parse_clients(&self.ipc.json("clients")?)) + } + + /// The union of all monitors, which absolute virtual-pointer coordinates span. + fn layout_box(monitors: &[Monitor]) -> Rect { + let rects: Vec = monitors.iter().map(Monitor::logical).collect(); + let x0 = rects.iter().map(|r| r.x).fold(f64::INFINITY, f64::min); + let y0 = rects.iter().map(|r| r.y).fold(f64::INFINITY, f64::min); + let x1 = rects.iter().map(|r| r.x + r.width).fold(f64::NEG_INFINITY, f64::max); + let y1 = rects.iter().map(|r| r.y + r.height).fold(f64::NEG_INFINITY, f64::max); + if rects.is_empty() { + return Rect { x: 0.0, y: 0.0, width: 1.0, height: 1.0 }; + } + Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } + } + + fn with_input(&self, f: impl FnOnce(&mut VirtualInput) -> std::result::Result) -> Result { + let mut g = self.lock(); + let vi = g.input.as_mut().ok_or_else(|| AdlError::new(ErrorCode::Disabled, "virtual input is not open; enable agent access first"))?; + match f(vi).and_then(|t| vi.roundtrip().map(|_| t)) { + Ok(t) => Ok(t), + Err(e) => { + // A broken connection cannot be trusted with held keys: drop it, which releases them. + g.input = None; + g.pending.push(SessionEvent::AdapterFailed(format!("Hyprland virtual input: {e}"))); + Err(AdlError::new(ErrorCode::OutcomeUnknown, format!("Hyprland virtual input: {e}"))) + } + } + } +} + +impl Adapter for HyprlandAdapter { + fn name(&self) -> String { + BACKEND.into() + } + + fn capabilities(&self) -> Vec { + let rep = |c, a, reason: &str, f: &[&str]| CapabilityReport { + capability: c, availability: a, backend: BACKEND.into(), reason: reason.into(), features: f.iter().map(|s| s.to_string()).collect::>(), + }; + let capture = if self.grim.is_some() { + rep(Capability::ScreenCapture, Availability::Available, "", &["grim", "wlr_screencopy"]) + } else { + rep(Capability::ScreenCapture, Availability::Unsupported, "install grim (wlr-screencopy) for screenshots", &[]) + }; + vec![ + rep(Capability::Pointer, Availability::Available, "", &["relative_motion", "pointer_position", "physical_input_detection", "button.back", "button.forward"]), + rep(Capability::Keyboard, Availability::Available, "keyboard takeover is not detectable on this backend", &["unicode_text", "generated_keymap"]), + capture, + rep(Capability::Windows, Availability::Available, "", &["window_at", "focus", "geometry"]), + rep(Capability::Accessibility, Availability::Unsupported, "the AT-SPI adapter is not implemented yet", &[]), + rep(Capability::ClipboardRead, Availability::Unsupported, "not implemented", &[]), + rep(Capability::ClipboardWrite, Availability::Unsupported, "not implemented", &[]), + ] + } + + fn begin(&self) -> Result<()> { + if self.monitors()?.is_empty() { + return Err(AdlError::new(ErrorCode::Blocked, "Hyprland reports no enabled monitors")); + } + let vi = VirtualInput::open().map_err(|e| AdlError::new(ErrorCode::Blocked, format!("Hyprland virtual input: {e}")))?; + let mut g = self.lock(); + g.input = Some(vi); + g.expected = None; + Ok(()) + } + + fn end(&self) { + let mut g = self.lock(); + g.input = None; + g.expected = None; + } + + fn layout(&self) -> Result { + let displays = self.monitors()?.iter().map(Monitor::display).collect(); + Ok(Layout { generation: self.lock().generation, displays }) + } + + fn windows(&self) -> Result> { + let (mons, clients) = (self.monitors()?, self.clients()?); + Ok(visible(&clients, &mons).into_iter().map(|c| window_info(c, &mons)).collect()) + } + + fn focused_window(&self) -> Result> { + let (mons, clients) = (self.monitors()?, self.clients()?); + Ok(visible(&clients, &mons).into_iter().find(|c| c.focus_history == 0).map(|c| window_info(c, &mons))) + } + + fn window_at(&self, x: f64, y: f64) -> Result> { + let (mons, clients) = (self.monitors()?, self.clients()?); + Ok(visible(&clients, &mons).into_iter().find(|c| c.bounds.contains(x, y)).map(|c| window_info(c, &mons))) + } + + fn inspect(&self, _window: &str) -> Result { + Err(AdlError::unsupported("accessibility trees on Linux need the AT-SPI adapter, which is not implemented yet")) + } + + fn capture(&self, display: &str) -> Result<(Vec, u32, u32)> { + let grim = self.grim.as_ref().ok_or_else(|| AdlError::unsupported("grim is not installed (w3bs.org/de installs it)"))?; + if !self.monitors()?.iter().any(|m| m.name == display) { + return Err(AdlError::not_found(format!("no monitor {display}"))); + } + let out = std::process::Command::new(grim).args(["-t", "png", "-l", "1", "-o", display, "-"]).output().map_err(|e| AdlError::internal(format!("grim: {e}")))?; + if !out.status.success() { + return Err(AdlError::internal(format!("grim failed: {}", String::from_utf8_lossy(&out.stderr).trim()))); + } + let png = out.stdout; + if png.len() < 24 || &png[12..16] != b"IHDR" { + return Err(AdlError::internal("grim did not return a PNG")); + } + let (w, h) = (u32::from_be_bytes(png[16..20].try_into().unwrap()), u32::from_be_bytes(png[20..24].try_into().unwrap())); + Ok((png, w, h)) + } + + fn pointer_position(&self) -> Result> { + let v = self.ipc.json("cursorpos")?; + Ok(Some((num(&v, "x"), num(&v, "y")))) + } + + fn emit(&self, event: &InputEvent) -> Result<()> { + match event { + InputEvent::MotionAbsolute { x, y } => { + let b = Self::layout_box(&self.monitors()?); + self.with_input(|vi| vi.motion_absolute(*x, *y, b))?; + self.lock().expected = Some((*x, *y)); + } + InputEvent::MotionRelative { dx, dy } => { + self.with_input(|vi| vi.motion_relative(*dx, *dy))?; + // Acceleration may apply; re-read where it landed. + self.lock().expected = self.pointer_position().ok().flatten(); + } + InputEvent::Button { button, pressed } => self.with_input(|vi| vi.button(*button, *pressed))?, + InputEvent::Scroll { axis, steps } => self.with_input(|vi| vi.scroll(*axis, *steps))?, + InputEvent::Key { key: Key::Modifier(m), pressed } => self.with_input(|vi| vi.modifier(*m, *pressed))?, + InputEvent::Key { key, pressed } => { + let sym = keymap::keysym(key).ok_or_else(|| AdlError::unsupported(format!("no keysym for {key}")))?; + self.with_input(|vi| vi.key(sym, *pressed))? + } + InputEvent::Text { text } => self.with_input(|vi| vi.text(text))?, + } + Ok(()) + } + + fn preflight(&self, action: &Action) -> Result<()> { + if let Action::KeyPress { key } | Action::KeyDown { key } | Action::KeyUp { key } = action + && matches!(key, Key::Modifier(Modifier::Command | Modifier::Option)) { + return Err(AdlError::unsupported("Command and Option are macOS keys; use primary, ctrl or alt")); + } + Ok(()) + } + + fn safety_interlock(&self) -> Option { + if let Some(l) = locker_running() { + return Some(format!("the session is locked ({l})")); + } + match self.clients() { + Ok(c) => auth_dialog(&c).map(|d| format!("an authentication dialog ({}) is on screen; the person must answer it", d.class)), + Err(e) => Some(format!("cannot read Hyprland state: {}", e.message)), + } + } + + fn poll_events(&self) -> Vec { + let expected = self.lock().expected; + if let Some((ex, ey)) = expected + && let Ok(Some((x, y))) = self.pointer_position() { + // Only the pointer can be checked; one report per departure. + if (x - ex).abs() > 4.0 || (y - ey).abs() > 4.0 { + let mut g = self.lock(); + if g.expected == expected { + g.expected = None; + g.pending.push(SessionEvent::PhysicalInput); + } + } + } + std::mem::take(&mut self.lock().pending) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn mons() -> Vec { + parse_monitors(&json!([ + {"id": 0, "name": "DP-1", "width": 2560, "height": 1440, "x": 0, "y": 0, "scale": 1.25, "transform": 0, + "activeWorkspace": {"id": 1, "name": "1"}, "specialWorkspace": {"id": 0, "name": ""}, "focused": true, "disabled": false}, + {"id": 1, "name": "HDMI-A-1", "width": 1920, "height": 1080, "x": 2048, "y": -200, "scale": 1.0, "transform": 1, + "activeWorkspace": {"id": 4, "name": "4"}, "specialWorkspace": {"id": -98, "name": "special:term"}, "focused": false, "disabled": false}, + {"id": 2, "name": "eDP-1", "width": 1, "height": 1, "x": 0, "y": 0, "scale": 1, "transform": 0, "disabled": true} + ])) + } + + fn client(addr: &str, class: &str, at: [f64; 2], size: [f64; 2], ws: i64, focus: i64) -> Value { + json!({"address": addr, "mapped": true, "hidden": false, "at": at, "size": size, "workspace": {"id": ws, "name": ws.to_string()}, + "floating": false, "monitor": 0, "class": class, "title": class, "pinned": false, "fullscreen": 0, "focusHistoryID": focus}) + } + + #[test] + fn monitors_map_to_logical_displays() { + let m = mons(); + assert_eq!(m.len(), 2, "disabled monitors are left out"); + let d = m[0].display(); + assert_eq!(d.logical, Rect { x: 0.0, y: 0.0, width: 2048.0, height: 1152.0 }); + assert_eq!(d.pixel_size(), (2560, 1440)); + let r = m[1].display(); + assert_eq!(r.rotation, Rotation::Rotate90); + assert_eq!(r.logical, Rect { x: 2048.0, y: -200.0, width: 1080.0, height: 1920.0 }, "a quarter turn swaps width and height"); + assert_eq!(HyprlandAdapter::layout_box(&m), Rect { x: 0.0, y: -200.0, width: 3128.0, height: 1920.0 }); + } + + #[test] + fn stacking_puts_special_fullscreen_and_floating_first() { + let mut floating = client("0xf", "pavucontrol", [100.0, 100.0], [400.0, 300.0], 1, 1); + floating["floating"] = json!(true); + let mut full = client("0xfs", "mpv", [0.0, 0.0], [2048.0, 1152.0], 1, 2); + full["fullscreen"] = json!(2); + let mut hidden = client("0xh", "foot", [0.0, 0.0], [10.0, 10.0], 1, 5); + hidden["hidden"] = json!(true); + let cs = parse_clients(&json!([ + client("0xa", "foot", [0.0, 0.0], [1024.0, 1152.0], 1, 0), + floating, + full, + client("0xs", "foot", [2100.0, 0.0], [500.0, 500.0], -98, 3), + client("0xo", "firefox", [0.0, 0.0], [2048.0, 1152.0], 2, 4), + hidden + ])); + let v: Vec<&str> = visible(&cs, &mons()).iter().map(|c| c.address.as_str()).collect(); + assert_eq!(v, ["0xs", "0xfs", "0xf", "0xa"], "other workspaces and hidden windows are not on screen"); + let w = window_info(visible(&cs, &mons())[3], &mons()); + assert!(w.focused && w.display.as_deref() == Some("DP-1")); + } + + #[test] + fn authentication_dialogs_are_found() { + let cs = parse_clients(&json!([client("0x1", "foot", [0.0, 0.0], [1.0, 1.0], 1, 0), client("0x2", "hyprpolkitagent", [0.0, 0.0], [1.0, 1.0], 1, 1)])); + assert_eq!(auth_dialog(&cs).unwrap().address, "0x2"); + assert!(auth_dialog(&cs[..1]).is_none()); + } + + #[test] + fn keys_sit_at_their_us_positions() { + let t = KeyTable::new(FREE_US_CODES); + // What a binding sees is what a real keyboard would send. + assert_eq!(t.lookup('e' as u32), Some((18, false))); + assert_eq!(t.lookup('E' as u32), Some((18, true)), "uppercase is Shift + the letter key"); + assert_eq!(t.lookup('1' as u32), Some((2, false))); + assert_eq!(t.lookup('!' as u32), Some((2, true))); + assert_eq!(t.lookup(0xff0d), Some((28, false)), "Enter"); + assert_eq!(t.lookup(SUPER_L), Some((125, false))); + // Never on a media key: evdev 113-115 are Mute and Volume on a US keymap. + for code in 113..=115 { + assert!(!t.fixed.contains_key(&code), "code {code} must stay unused"); + } + let km = t.keymap(); + assert!(km.contains("key { [ 0x00000065, 0x00000045 ] };"), "e/E"); + assert!(km.contains("modifier_map Mod4 { };")); + assert!(km.contains(" = 26;")); + } + + #[test] + fn other_symbols_use_only_free_codes_and_make_room() { + let mut t = KeyTable::new(FREE_US_CODES); + assert_eq!(t.capacity(), FREE_US_CODES.len()); + let e_acute = keymap::char_keysym('é'); + assert!(t.lookup(e_acute).is_none()); + assert!(t.assign(&[e_acute])); + let (code, shift) = t.lookup(e_acute).unwrap(); + assert!(FREE_US_CODES.contains(&code) && !shift); + assert!(t.keymap().contains(&format!("key {{ [ 0x{e_acute:08x} ] }};"))); + assert!(!t.assign(&[e_acute, 'a' as u32]), "nothing new to place"); + // A full set of new symbols evicts the ones no longer wanted. + let cjk: Vec = (0..FREE_US_CODES.len() as u32).map(|i| 0x0100_4e00 + i).collect(); + assert!(t.assign(&cjk)); + assert!(t.lookup(e_acute).is_none()); + assert!(cjk.iter().all(|s| t.lookup(*s).is_some_and(|(c, _)| FREE_US_CODES.contains(&c)))); + } + + #[test] + fn modifier_masks() { + assert_eq!(modifier_mask(Modifier::Shift) | modifier_mask(Modifier::Primary) | modifier_mask(Modifier::Alt) | modifier_mask(Modifier::Super), 1 | 4 | 8 | 64); + } + + #[test] + fn ipc_requests_one_connection_each() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(".socket.sock"); + let l = std::os::unix::net::UnixListener::bind(&path).unwrap(); + let t = std::thread::spawn(move || { + for _ in 0..2 { + let (mut s, _) = l.accept().unwrap(); + let mut buf = [0u8; 64]; + let n = s.read(&mut buf).unwrap(); + let req = String::from_utf8_lossy(&buf[..n]).to_string(); + let reply = if req == "j/cursorpos" { r#"{"x": 12.5, "y": 40}"# } else { "[]" }; + s.write_all(reply.as_bytes()).unwrap(); + } + }); + let ipc = HyprIpc::at(dir.path().into()); + assert_eq!(ipc.json("cursorpos").unwrap()["x"], 12.5); + assert!(parse_clients(&ipc.json("clients").unwrap()).is_empty()); + t.join().unwrap(); + } +} diff --git a/desktop/crates/adl-linux/src/lib.rs b/desktop/crates/adl-linux/src/lib.rs index dcde716..3193a9a 100644 --- a/desktop/crates/adl-linux/src/lib.rs +++ b/desktop/crates/adl-linux/src/lib.rs @@ -2,6 +2,8 @@ //! //! - [`vela`]: Vela compositor command socket (observation, focus, occlusion, lock and //! authentication-prompt interlocks). Clean-room from interface facts. +//! - [`hyprland`]: Hyprland (W3BS DE): IPC for layout and windows, wlroots virtual pointer and +//! keyboard for input, grim for captures. //! - [`portal`]: KDE Plasma / GNOME input and capture through the XDG desktop portals. //! - [`pipewire`]: the portal session's monitors as live PipeWire streams, for recording. //! - [`kwin`]: KDE Plasma window list, stacking, focus, activation and the polkit interlock via @@ -13,6 +15,7 @@ //! - [`xkb`]: which characters Mutter can type, from the compositor's live keymap. //! - [`screensaver`]: lock events from the desktop's ScreenSaver service (no logind needed). +pub mod hyprland; pub mod keymap; #[cfg(feature = "dbus")] pub mod gnome; @@ -25,6 +28,7 @@ pub mod pipewire; #[cfg(feature = "dbus")] pub mod portal; pub mod vela; +pub mod wl; pub mod xkb; #[cfg(feature = "dbus")] pub mod screensaver; diff --git a/desktop/crates/adl-linux/src/wl.rs b/desktop/crates/adl-linux/src/wl.rs new file mode 100644 index 0000000..5e85514 --- /dev/null +++ b/desktop/crates/adl-linux/src/wl.rs @@ -0,0 +1,177 @@ +//! A minimal Wayland client wire: enough to bind globals, send requests (with file descriptors) +//! and read events, without libwayland. Used to read the live keymap (`xkb`) and to drive the +//! wlroots virtual pointer and keyboard (`hyprland`). + +use std::collections::VecDeque; +use std::ffi::c_void; +use std::io::Write; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; +use std::os::unix::net::UnixStream; +use std::time::Duration; + +#[derive(Default)] +pub struct Args(pub Vec); + +impl Args { + pub fn new() -> Self { + Self(vec![]) + } + pub fn u32(mut self, v: u32) -> Self { + self.0.extend_from_slice(&v.to_ne_bytes()); + self + } + pub fn string(mut self, s: &str) -> Self { + self.0.extend_from_slice(&(s.len() as u32 + 1).to_ne_bytes()); + self.0.extend_from_slice(s.as_bytes()); + self.0.push(0); + while !self.0.len().is_multiple_of(4) { + self.0.push(0); + } + self + } +} + +/// Reader over one event's arguments. +pub struct ArgReader<'a>(pub &'a [u8]); + +impl ArgReader<'_> { + pub fn u32(&mut self) -> Result { + let (h, t) = self.0.split_at_checked(4).ok_or("short Wayland event")?; + self.0 = t; + Ok(u32::from_ne_bytes(h.try_into().unwrap())) + } + pub fn string(&mut self) -> Result { + let n = self.u32()? as usize; + let padded = n.div_ceil(4) * 4; + let (h, t) = self.0.split_at_checked(padded).ok_or("short Wayland string")?; + self.0 = t; + Ok(String::from_utf8_lossy(&h[..n.saturating_sub(1)]).into_owned()) + } +} + +pub struct Wire { + pub sock: UnixStream, + pub buf: Vec, + pub fds: VecDeque, +} + +impl Wire { + /// Connect to the session's compositor (`$WAYLAND_DISPLAY` under `$XDG_RUNTIME_DIR`). + pub fn connect(read_timeout: Duration) -> Result { + let display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into()); + let path = if display.starts_with('/') { + display.into() + } else { + std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").ok_or("XDG_RUNTIME_DIR is not set")?).join(display) + }; + let sock = UnixStream::connect(&path).map_err(|e| format!("Wayland {}: {e}", path.display()))?; + sock.set_read_timeout(Some(read_timeout)).map_err(|e| e.to_string())?; + Ok(Self { sock, buf: vec![], fds: VecDeque::new() }) + } + + /// Send a request carrying one file descriptor (SCM_RIGHTS), e.g. a keymap. + pub fn send_fd(&mut self, obj: u32, opcode: u16, args: &Args, fd: RawFd) -> Result<(), String> { + let len = 8 + args.0.len(); + let mut m = Vec::with_capacity(len); + m.extend_from_slice(&obj.to_ne_bytes()); + m.extend_from_slice(&(((len as u32) << 16) | opcode as u32).to_ne_bytes()); + m.extend_from_slice(&args.0); + let mut cbuf = [0u64; 4]; + let mut iov = libc::iovec { iov_base: m.as_mut_ptr() as *mut c_void, iov_len: m.len() }; + // SAFETY: one SCM_RIGHTS control message with one fd, built in a stack buffer large + // enough for CMSG_SPACE(sizeof(int)); the iovec and buffers outlive the sendmsg call. + let n = unsafe { + let mut msg: libc::msghdr = std::mem::zeroed(); + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + msg.msg_control = cbuf.as_mut_ptr() as *mut c_void; + msg.msg_controllen = libc::CMSG_SPACE(std::mem::size_of::() as u32) as _; + let c = libc::CMSG_FIRSTHDR(&msg); + (*c).cmsg_level = libc::SOL_SOCKET; + (*c).cmsg_type = libc::SCM_RIGHTS; + (*c).cmsg_len = libc::CMSG_LEN(std::mem::size_of::() as u32) as _; + std::ptr::write_unaligned(libc::CMSG_DATA(c) as *mut RawFd, fd); + libc::sendmsg(self.sock.as_raw_fd(), &msg, libc::MSG_NOSIGNAL) + }; + if n as usize != m.len() { + return Err(format!("Wayland write: {}", std::io::Error::last_os_error())); + } + Ok(()) + } + + pub fn send(&mut self, obj: u32, opcode: u16, args: &Args) -> Result<(), String> { + let len = 8 + args.0.len(); + let mut m = Vec::with_capacity(len); + m.extend_from_slice(&obj.to_ne_bytes()); + m.extend_from_slice(&(((len as u32) << 16) | opcode as u32).to_ne_bytes()); + m.extend_from_slice(&args.0); + self.sock.write_all(&m).map_err(|e| format!("Wayland write: {e}")) + } + + /// Read more bytes, collecting any file descriptors passed alongside them. + pub fn fill(&mut self) -> Result<(), String> { + let mut data = [0u8; 4096]; + let mut cbuf = [0u64; 64]; + let mut iov = libc::iovec { iov_base: data.as_mut_ptr() as *mut c_void, iov_len: data.len() }; + // SAFETY: recvmsg into stack buffers that outlive the call; control messages are walked + // with the libc CMSG helpers within msg_controllen. + unsafe { + let mut msg: libc::msghdr = std::mem::zeroed(); + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + msg.msg_control = cbuf.as_mut_ptr() as *mut c_void; + msg.msg_controllen = std::mem::size_of_val(&cbuf) as _; + let fd = self.sock.as_raw_fd(); + let n = libc::recvmsg(fd, &mut msg, libc::MSG_CMSG_CLOEXEC); + if n < 0 { + return Err(format!("Wayland read: {}", std::io::Error::last_os_error())); + } + if n == 0 { + return Err("the compositor closed the Wayland connection".into()); + } + let mut c = libc::CMSG_FIRSTHDR(&msg); + while !c.is_null() { + if (*c).cmsg_level == libc::SOL_SOCKET && (*c).cmsg_type == libc::SCM_RIGHTS { + let data = libc::CMSG_DATA(c) as *const RawFd; + let count = ((*c).cmsg_len as usize - libc::CMSG_LEN(0) as usize) / std::mem::size_of::(); + for i in 0..count { + self.fds.push_back(OwnedFd::from_raw_fd(std::ptr::read_unaligned(data.add(i)))); + } + } + c = libc::CMSG_NXTHDR(&msg, c); + } + self.buf.extend_from_slice(&data[..n as usize]); + } + Ok(()) + } + + /// Dispatch events to `f` until wl_callback `cb` is done. + pub fn until_done(&mut self, cb: u32, mut f: impl FnMut(u32, u16, &mut ArgReader, &mut VecDeque) -> Result<(), String>) -> Result<(), String> { + loop { + while self.buf.len() >= 8 { + let obj = u32::from_ne_bytes(self.buf[0..4].try_into().unwrap()); + let word = u32::from_ne_bytes(self.buf[4..8].try_into().unwrap()); + let (len, op) = ((word >> 16) as usize, (word & 0xffff) as u16); + if len < 8 { + return Err("malformed Wayland message".into()); + } + if self.buf.len() < len { + break; + } + let body: Vec = self.buf[8..len].to_vec(); + self.buf.drain(..len); + let mut a = ArgReader(&body); + if obj == 1 && op == 0 { + let (_, code, msg) = (a.u32()?, a.u32()?, a.string()?); + return Err(format!("Wayland protocol error {code}: {msg}")); + } + if obj == cb && op == 0 { + return Ok(()); + } + f(obj, op, &mut a, &mut self.fds)?; + } + self.fill()?; + } + } +} + diff --git a/desktop/crates/adl-linux/src/xkb.rs b/desktop/crates/adl-linux/src/xkb.rs index ae71ee2..4ee463c 100644 --- a/desktop/crates/adl-linux/src/xkb.rs +++ b/desktop/crates/adl-linux/src/xkb.rs @@ -17,12 +17,9 @@ //! typeable in every group. If the keymap cannot be read, only printable ASCII is allowed. use crate::keymap; -use std::collections::VecDeque; +use crate::wl::{Args, Wire}; use std::ffi::{c_char, c_int, c_void, CStr, CString}; -use std::io::Write; -use std::os::fd::{FromRawFd, OwnedFd, RawFd}; use std::os::unix::fs::FileExt; -use std::os::unix::net::UnixStream; use std::sync::OnceLock; use std::time::Duration; @@ -221,20 +218,47 @@ pub fn compile(text: &[u8], group: Option) -> Result, String> { } } +/// XKB keycodes (9..=255) that produce no keysym in any layout or level of this keymap: keys a +/// key binding can never match, which a virtual keyboard can use for symbols of its own. +pub fn empty_keycodes(text: &[u8]) -> Result, String> { + let x = xkb()?; + let text = CString::new(text.split(|b| *b == 0).next().unwrap_or_default()).map_err(|e| e.to_string())?; + // SAFETY: plain libxkbcommon calls on objects created and released here (as in `compile`). + unsafe { + let ctx = (x.context_new)(3); + if ctx.is_null() { + return Err("xkb_context_new failed".into()); + } + let km = (x.keymap_new_from_string)(ctx, text.as_ptr(), 1, 0); + if km.is_null() { + (x.context_unref)(ctx); + return Err("the compositor's keymap did not compile".into()); + } + let (min, max, n) = ((x.min_keycode)(km), (x.max_keycode)(km), (x.num_layouts)(km).max(1)); + let empty = (9..=255u32) + .filter(|&kc| { + kc < min + || kc > max + || (0..n).all(|g| { + (0..(x.num_levels_for_key)(km, kc, g)).all(|level| { + let mut syms: *const u32 = std::ptr::null(); + (x.key_get_syms_by_level)(km, kc, g, level, &mut syms) <= 0 + }) + }) + }) + .collect(); + (x.keymap_unref)(km); + (x.context_unref)(ctx); + Ok(empty) + } +} + // ---------------------------------------------------------------- Wayland: wl_keyboard.keymap /// Read the keymap the compositor sends every Wayland client, over a minimal client connection /// (registry, wl_seat, wl_keyboard; nothing is drawn and no input is read). pub fn wayland_keymap() -> Result, String> { - let display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into()); - let path = if display.starts_with('/') { - display.into() - } else { - std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").ok_or("XDG_RUNTIME_DIR is not set")?).join(display) - }; - let sock = UnixStream::connect(&path).map_err(|e| format!("Wayland {}: {e}", path.display()))?; - sock.set_read_timeout(Some(Duration::from_secs(2))).map_err(|e| e.to_string())?; - let mut w = Wire { sock, buf: vec![], fds: VecDeque::new() }; + let mut w = Wire::connect(Duration::from_secs(2))?; const DISPLAY: u32 = 1; const REGISTRY: u32 = 2; const SEAT: u32 = 4; @@ -285,132 +309,13 @@ pub fn wayland_keymap() -> Result, String> { keymap.unwrap_or_else(|| Err("the compositor sent no keymap".into())) } -struct Args(Vec); - -impl Args { - fn new() -> Self { - Self(vec![]) - } - fn u32(mut self, v: u32) -> Self { - self.0.extend_from_slice(&v.to_ne_bytes()); - self - } - fn string(mut self, s: &str) -> Self { - self.0.extend_from_slice(&(s.len() as u32 + 1).to_ne_bytes()); - self.0.extend_from_slice(s.as_bytes()); - self.0.push(0); - while !self.0.len().is_multiple_of(4) { - self.0.push(0); - } - self - } -} - -/// Reader over one event's arguments. -struct ArgReader<'a>(&'a [u8]); - -impl ArgReader<'_> { - fn u32(&mut self) -> Result { - let (h, t) = self.0.split_at_checked(4).ok_or("short Wayland event")?; - self.0 = t; - Ok(u32::from_ne_bytes(h.try_into().unwrap())) - } - fn string(&mut self) -> Result { - let n = self.u32()? as usize; - let padded = n.div_ceil(4) * 4; - let (h, t) = self.0.split_at_checked(padded).ok_or("short Wayland string")?; - self.0 = t; - Ok(String::from_utf8_lossy(&h[..n.saturating_sub(1)]).into_owned()) - } -} - -struct Wire { - sock: UnixStream, - buf: Vec, - fds: VecDeque, -} - -impl Wire { - fn send(&mut self, obj: u32, opcode: u16, args: &Args) -> Result<(), String> { - let len = 8 + args.0.len(); - let mut m = Vec::with_capacity(len); - m.extend_from_slice(&obj.to_ne_bytes()); - m.extend_from_slice(&(((len as u32) << 16) | opcode as u32).to_ne_bytes()); - m.extend_from_slice(&args.0); - self.sock.write_all(&m).map_err(|e| format!("Wayland write: {e}")) - } - - /// Read more bytes, collecting any file descriptors passed alongside them. - fn fill(&mut self) -> Result<(), String> { - let mut data = [0u8; 4096]; - let mut cbuf = [0u64; 64]; - let mut iov = libc::iovec { iov_base: data.as_mut_ptr() as *mut c_void, iov_len: data.len() }; - // SAFETY: recvmsg into stack buffers that outlive the call; control messages are walked - // with the libc CMSG helpers within msg_controllen. - unsafe { - let mut msg: libc::msghdr = std::mem::zeroed(); - msg.msg_iov = &mut iov; - msg.msg_iovlen = 1; - msg.msg_control = cbuf.as_mut_ptr() as *mut c_void; - msg.msg_controllen = std::mem::size_of_val(&cbuf) as _; - let fd = std::os::fd::AsRawFd::as_raw_fd(&self.sock); - let n = libc::recvmsg(fd, &mut msg, libc::MSG_CMSG_CLOEXEC); - if n < 0 { - return Err(format!("Wayland read: {}", std::io::Error::last_os_error())); - } - if n == 0 { - return Err("the compositor closed the Wayland connection".into()); - } - let mut c = libc::CMSG_FIRSTHDR(&msg); - while !c.is_null() { - if (*c).cmsg_level == libc::SOL_SOCKET && (*c).cmsg_type == libc::SCM_RIGHTS { - let data = libc::CMSG_DATA(c) as *const RawFd; - let count = ((*c).cmsg_len as usize - libc::CMSG_LEN(0) as usize) / std::mem::size_of::(); - for i in 0..count { - self.fds.push_back(OwnedFd::from_raw_fd(std::ptr::read_unaligned(data.add(i)))); - } - } - c = libc::CMSG_NXTHDR(&msg, c); - } - self.buf.extend_from_slice(&data[..n as usize]); - } - Ok(()) - } - - /// Dispatch events to `f` until wl_callback `cb` is done. - fn until_done(&mut self, cb: u32, mut f: impl FnMut(u32, u16, &mut ArgReader, &mut VecDeque) -> Result<(), String>) -> Result<(), String> { - loop { - while self.buf.len() >= 8 { - let obj = u32::from_ne_bytes(self.buf[0..4].try_into().unwrap()); - let word = u32::from_ne_bytes(self.buf[4..8].try_into().unwrap()); - let (len, op) = ((word >> 16) as usize, (word & 0xffff) as u16); - if len < 8 { - return Err("malformed Wayland message".into()); - } - if self.buf.len() < len { - break; - } - let body: Vec = self.buf[8..len].to_vec(); - self.buf.drain(..len); - let mut a = ArgReader(&body); - if obj == 1 && op == 0 { - let (_, code, msg) = (a.u32()?, a.u32()?, a.string()?); - return Err(format!("Wayland protocol error {code}: {msg}")); - } - if obj == cb && op == 0 { - return Ok(()); - } - f(obj, op, &mut a, &mut self.fds)?; - } - self.fill()?; - } - } -} - #[cfg(test)] mod tests { use super::*; - use std::io::Read; + use crate::wl::ArgReader; + use std::io::{Read, Write}; + use std::os::fd::RawFd; + use std::os::unix::net::UnixStream; /// A slice of a `us` keymap: keycodes ascending, levels per key. fn us() -> Group { diff --git a/desktop/crates/w3bs-desktop/src/lib.rs b/desktop/crates/w3bs-desktop/src/lib.rs index 65bb9d5..007a44d 100644 --- a/desktop/crates/w3bs-desktop/src/lib.rs +++ b/desktop/crates/w3bs-desktop/src/lib.rs @@ -61,8 +61,10 @@ struct Cli { #[derive(Clone, Copy, ValueEnum, PartialEq)] enum AdapterKind { - /// Vela if running, else the XDG portal backend (KDE Plasma, GNOME; windows from KWin or the W3BS GNOME Shell extension); never the mock + /// Vela if running, else Hyprland (W3BS DE), else the XDG portal backend (KDE Plasma, GNOME; windows from KWin or the W3BS GNOME Shell extension); never the mock Auto, + /// Hyprland (W3BS DE): IPC for windows, wlroots virtual pointer/keyboard for input, grim for captures + Hyprland, /// Vela compositor command socket (observation and interlocks; input needs VELA_DEBUG_INPUT) Vela, /// XDG RemoteDesktop/ScreenCast/Screenshot portals (KDE Plasma, GNOME); windows come from KWin or the GNOME Shell extension @@ -120,7 +122,11 @@ enum Cmd { wait_for_broker: u64, }, /// Show the layer state - Status, + Status { + /// One line of waybar custom-module JSON (text, class off|on|driving|waiting, tooltip), for the W3BS DE bar + #[arg(long)] + waybar: bool, + }, /// Ask to enable agent access (confirm in the native control or with its code) Enable, /// Disable agent access and stop everything mediated by the layer @@ -744,6 +750,26 @@ fn print(v: &Value, json_out: bool) { } } +/// The W3BS DE bar's agent indicator: off, on, which agent is driving, or a person is needed. +fn waybar_status(v: &Value, agents: &Value) -> Value { + let name = |id: &str| agents.as_array().and_then(|a| a.iter().find(|x| x["id"] == id)).and_then(|x| x["name"].as_str()).unwrap_or(id).to_string(); + let n = |k: &str| v[k].as_u64().unwrap_or(0); + let waiting = n("pending_drive_requests") + n("pending_human_steps") + n("pending_approvals"); + let state = v["state"].as_str().unwrap_or("disabled"); + let (text, class) = if waiting > 0 { + ("agent needs you".to_string(), "waiting") + } else if let Some(agent) = v["lease"]["agent"].as_str() { + (format!("{} driving", name(agent)), "driving") + } else if state == "enabled" { + ("agents on".to_string(), "on") + } else { + ("agents off".to_string(), "off") + }; + let mut tip = status_line(v); + tip += "\nClick: control app (SUPER+A). Right-click: turn agent access off (SUPER+ALT+Escape)."; + json!({"text": text, "class": class, "tooltip": tip}) +} + fn status_line(v: &Value) -> String { let mut s = format!("Agent access: {}", v["state"].as_str().unwrap_or("?").to_uppercase()); if let Some(r) = v["blocked_reason"].as_str() { @@ -844,8 +870,13 @@ fn run_with(cli: Cli) { control::run().map_err(|e| AdlError::new(ErrorCode::Blocked, e.to_string()))?; Ok(Value::Null) } - Cmd::Status => { - let v = control_client()?.call("status", json!({}))?; + Cmd::Status { waybar } => { + let mut c = control_client()?; + let v = c.call("status", json!({}))?; + if waybar { + let agents = c.call("agents.list", json!({})).unwrap_or(Value::Null); + return Ok(json!(waybar_status(&v, &agents).to_string())); + } Ok(if j { v } else { json!(status_line(&v)) }) } Cmd::Enable => { diff --git a/desktop/crates/w3bs-desktop/src/platform/linux.rs b/desktop/crates/w3bs-desktop/src/platform/linux.rs index b20bff4..6b14f2d 100644 --- a/desktop/crates/w3bs-desktop/src/platform/linux.rs +++ b/desktop/crates/w3bs-desktop/src/platform/linux.rs @@ -1,4 +1,5 @@ -//! Linux: Vela command socket, or XDG portals with KWin / GNOME Shell window data. +//! Linux: Vela command socket, Hyprland (IPC + wlroots virtual input), or XDG portals with KWin / +//! GNOME Shell window data. use crate::paths; use crate::AdapterKind; @@ -9,7 +10,7 @@ use std::sync::Arc; pub fn make_adapter(kind: AdapterKind) -> Result> { use adl_linux::gnome::{self, ShellBus, ShellObserver, WithGnome}; use adl_linux::kwin::{KWinObserver, KWinScripts, WithKWin}; - use adl_linux::{portal::PortalAdapter, vela::VelaAdapter}; + use adl_linux::{hyprland::HyprlandAdapter, portal::PortalAdapter, vela::VelaAdapter}; let vela = || VelaAdapter::default_socket().map(VelaAdapter::new).ok_or_else(|| AdlError::new(ErrorCode::Blocked, "Vela command socket not found (needs XDG_RUNTIME_DIR and WAYLAND_DISPLAY)")); // The portal backend gets its window list, focus and stacking from KWin on Plasma and from // the W3BS Shell extension on GNOME. @@ -42,7 +43,10 @@ pub fn make_adapter(kind: AdapterKind) -> Result> { AdapterKind::Native => return Err(AdlError::unsupported("`native` is the Windows/macOS backend; on Linux use auto, portal or vela")), AdapterKind::Vela => Arc::new(vela()?), AdapterKind::Portal => portal(), + AdapterKind::Hyprland => Arc::new(HyprlandAdapter::from_env()?), AdapterKind::Auto if VelaAdapter::detect() => Arc::new(vela()?), + // Hyprland's portal has no RemoteDesktop: it gets its own backend, checked before portals. + AdapterKind::Auto if HyprlandAdapter::detect() => Arc::new(HyprlandAdapter::from_env()?), AdapterKind::Auto if PortalAdapter::detect() => portal(), AdapterKind::Auto => return Err(AdlError::new(ErrorCode::Blocked, "no supported desktop session found (need a Wayland/X11 session with D-Bus, or Vela); use --adapter mock for a demo")), }) diff --git a/desktop/crates/w3bs-desktop/src/platform/macos.rs b/desktop/crates/w3bs-desktop/src/platform/macos.rs index 3d56ad0..7ef34dc 100644 --- a/desktop/crates/w3bs-desktop/src/platform/macos.rs +++ b/desktop/crates/w3bs-desktop/src/platform/macos.rs @@ -10,7 +10,7 @@ pub fn make_adapter(kind: AdapterKind) -> Result> { match kind { AdapterKind::Mock => Ok(Arc::new(adl_broker::mock::MockDesktop::new())), AdapterKind::Auto | AdapterKind::Native => Ok(Arc::new(adl_macos::MacAdapter::new())), - AdapterKind::Vela | AdapterKind::Portal => Err(AdlError::unsupported("vela and portal are Linux backends; on macOS use auto or native")), + AdapterKind::Vela | AdapterKind::Portal | AdapterKind::Hyprland => Err(AdlError::unsupported("vela, portal and hyprland are Linux backends; on macOS use auto or native")), } } diff --git a/desktop/crates/w3bs-desktop/src/platform/windows.rs b/desktop/crates/w3bs-desktop/src/platform/windows.rs index dc7a8d3..c533d9d 100644 --- a/desktop/crates/w3bs-desktop/src/platform/windows.rs +++ b/desktop/crates/w3bs-desktop/src/platform/windows.rs @@ -19,7 +19,7 @@ pub fn make_adapter(kind: AdapterKind) -> Result> { } Ok(Arc::new(adl_windows::WindowsAdapter::new())) } - AdapterKind::Vela | AdapterKind::Portal => Err(AdlError::unsupported("vela and portal are Linux backends; on Windows use auto or native")), + AdapterKind::Vela | AdapterKind::Portal | AdapterKind::Hyprland => Err(AdlError::unsupported("vela, portal and hyprland are Linux backends; on Windows use auto or native")), } } diff --git a/desktop/tests/rig/hyprland-e2e.sh b/desktop/tests/rig/hyprland-e2e.sh new file mode 100755 index 0000000..7ffed0d --- /dev/null +++ b/desktop/tests/rig/hyprland-e2e.sh @@ -0,0 +1,50 @@ +#!/bin/sh +# End to end on real Hyprland: the Agentic Desktop Layer's linux-hyprland backend in the rig. +# +# cargo build -p w3bs-desktop && desktop/tests/rig/hyprland.sh && desktop/tests/rig/hyprland-e2e.sh +# +# Copies the built w3bs-desktop into the running rig, starts a foot window and a broker with +# --adapter auto, and checks: backend chosen, windows and layout, a 1920x1080 capture, Unicode +# typing landing exactly, absolute pointer motion, a click. +set -eu +NAME=${NAME:-w3bs-hyprland-rig} +BIN=${W3BS_DESKTOP_BIN:-${CARGO_TARGET_DIR:-$(dirname "$0")/../../target}/debug/w3bs-desktop} +[ -x "$BIN" ] || { echo "build first: cargo build -p w3bs-desktop (or set W3BS_DESKTOP_BIN)" >&2; exit 2; } +docker cp "$BIN" "$NAME":/usr/local/bin/w3bs-desktop >/dev/null +docker exec -i "$NAME" sh -s <<'IN' +# Runs inside the rig: the Agentic Desktop Layer against real Hyprland. +. /run/user/0/rig.env +export W3BS_DESKTOP_RUNTIME_DIR=/tmp/adl/run W3BS_DESKTOP_CONFIG_DIR=/tmp/adl/cfg XDG_STATE_HOME=/tmp/adl/state +rm -rf /tmp/adl; mkdir -p /tmp/adl/run /tmp/adl/cfg /tmp/adl/state; chmod 700 /tmp/adl/run +pkill foot; rm -f /tmp/typed.txt +foot -T T1 sh -c 'stty -echo; cat > /tmp/typed.txt' >/dev/null 2>&1 & +for i in $(seq 50); do hyprctl -j clients | grep -q '"T1"' && break; sleep 0.2; done +w3bs-desktop daemon --adapter auto --enabled-by-default > /tmp/adl/daemon.log 2>&1 & +sleep 1.5 +fail=0; check() { if [ "$1" = ok ]; then echo "PASS $2"; else echo "FAIL $2"; fail=1; fi; } +w3bs-desktop agents add bot --preset broad >/dev/null 2>&1 +w3bs-desktop capabilities 2>&1 | grep -q '"linux-hyprland"' && check ok "backend is linux-hyprland" || { check no "backend"; w3bs-desktop capabilities | head -20; cat /tmp/adl/daemon.log; } +W=$(w3bs-desktop windows list --agent bot 2>&1) +echo "$W" | grep -q '"app_id": "foot"' && check ok "windows: foot listed" || { check no "windows"; echo "$W"; } +WID=$(echo "$W" | sed -n 's/.*"id": "\(0x[0-9a-f]*\)".*/\1/p' | head -1) +w3bs-desktop layout --agent bot 2>&1 | grep -q '"HEADLESS-1"' && check ok "layout: HEADLESS-1" || check no "layout" +w3bs-desktop screen capture --agent bot --display HEADLESS-1 --output /tmp/adl/shot.png >/dev/null 2>&1 && file /tmp/adl/shot.png | grep -q "1920 x 1080" && check ok "capture 1920x1080 PNG" || check no "capture" +# Every US key (both levels), accents, and more distinct non-US symbols (20) than a US keymap has +# empty keycodes (14), so the typing-in-parts path runs. With W3BS DE's media-key bindings loaded, +# a letter on a media keycode would vanish. +cat > /tmp/adl/text.txt <<'TXT' +hello W3BS: héllo wörld ✓ 日本 +abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789 +!@#$%^&*()_+-=[]{};':",./<>?\|`~ +一二三四五六七八九十百千万円年月日時分秒 +TXT +w3bs-desktop keyboard type --agent bot --window "$WID" --from-file /tmp/adl/text.txt > /tmp/adl/type.out 2>&1 || cat /tmp/adl/type.out +sleep 1 +if [ "$(sha256sum < /tmp/typed.txt)" = "$(sha256sum < /tmp/adl/text.txt)" ]; then check ok "typed 4 lines exactly (ASCII, shifted, accents, 20 CJK)"; else check no "typed text differs"; od -c /tmp/typed.txt | head; fi +w3bs-desktop mouse move --agent bot --x 700 --y 400 >/dev/null 2>&1 +pos=$(hyprctl -j cursorpos) +echo "$pos" | grep -q '"x": 700' && echo "$pos" | grep -q '"y": 400' && check ok "absolute move -> $pos" || check no "move: $pos" +w3bs-desktop mouse click --agent bot --window "$WID" --x 300 --y 300 > /tmp/adl/click.out 2>&1 && check ok "click delivered" || { check no click; cat /tmp/adl/click.out; } +echo "--- daemon log"; tail -5 /tmp/adl/daemon.log +exit $fail +IN diff --git a/desktop/tests/rig/hyprland.sh b/desktop/tests/rig/hyprland.sh new file mode 100755 index 0000000..ac22e8e --- /dev/null +++ b/desktop/tests/rig/hyprland.sh @@ -0,0 +1,63 @@ +#!/bin/sh +# Headless Hyprland (0.56, Lua config) in Docker, for driving agent automation against a real +# Hyprland session: grim screenshots, wtype keyboard, wlrctl pointer, hyprctl IPC. +# +# desktop/tests/rig/hyprland.sh # build image, (re)start container, wait, print exec env +# desktop/tests/rig/hyprland.sh stop # remove the container +# desktop/tests/rig/hyprland.sh env # print the -e flags of the running session +# desktop/tests/rig/hyprland.sh exec CMD.. # docker exec CMD inside the session (any shell) +# W3BS_RIG_WORK=/path/to/bin desktop/tests/rig/hyprland.sh # mounted read-only at /work +# +# How it works: Hyprland runs its normal DRM backend on the host's /dev/dri/card* (opened through +# seatd, non-VT-bound) and renders with Mesa on /dev/dri/renderD*. A headless box has every +# connector disconnected, so the entrypoint adds a virtual output (`hyprctl output create headless`). +# Hyprland 0.56 cannot start on the headless backend alone: without a DRM device aquamarine fails +# with "Cannot open backend: no allocator available". Builds context: ./hyprland/. +set -eu +HERE=$(cd "$(dirname "$0")" && pwd) +NAME=${NAME:-w3bs-hyprland-rig} +IMAGE=${IMAGE:-w3bs-hyprland-rig:latest} +CARD=${CARD:-$(ls /dev/dri/card* 2>/dev/null | head -1)} +RENDER=${RENDER:-$(ls /dev/dri/renderD* 2>/dev/null | head -1)} + +print_env() { + docker exec "$NAME" cat /run/user/0/rig.env | sed -n 's/^export //p' | tr ' ' '\n' | sed 's/^/-e /' | tr '\n' ' ' + echo +} + +# shellcheck disable=SC2046 +case "${1:-start}" in + stop) docker rm -f "$NAME" >/dev/null 2>&1 || true; echo "stopped $NAME"; exit 0 ;; + env) print_env; exit 0 ;; + # print_env is a list of -e flags: word splitting is intended. + exec) shift; exec docker exec $(print_env) "$NAME" "$@" ;; + start) ;; + *) echo "usage: $0 [start|stop|env|exec CMD...]" >&2; exit 64 ;; +esac + +[ -n "$CARD" ] && [ -n "$RENDER" ] || { echo "need /dev/dri/card* and /dev/dri/renderD* on this host" >&2; exit 2; } +docker build -q -t "$IMAGE" "$HERE/hyprland" >/dev/null +docker rm -f "$NAME" >/dev/null 2>&1 || true # one rig at a time + +set -- --memory 6g --cpus 4 --init --device "$CARD" --device "$RENDER" -e AQ_DRM_DEVICES="$CARD" +[ -n "${W3BS_RIG_WORK:-}" ] && set -- "$@" -v "$(cd "$W3BS_RIG_WORK" && pwd)":/work:ro +docker run -d --name "$NAME" "$@" "$IMAGE" >/dev/null + +i=0 +until docker exec "$NAME" test -s /run/user/0/rig.env 2>/dev/null; do + i=$((i+1)) + if [ $i -gt 150 ] || [ "$(docker inspect -f '{{.State.Running}}' "$NAME" 2>/dev/null)" != true ]; then + echo "Hyprland did not come up; last log lines:" >&2 + docker logs "$NAME" 2>&1 | tail -5 >&2 + docker exec "$NAME" sh -c 'tail -20 /var/log/rig/hyprland.log' >&2 2>/dev/null || true + exit 1 + fi + sleep 0.2 +done + +ENV=$(print_env) +echo "Hyprland $(docker exec -e XDG_RUNTIME_DIR=/run/user/0 "$NAME" Hyprland --version | head -1 | awk '{print $2}') running in $NAME" +docker exec $ENV "$NAME" hyprctl -j monitors | docker exec -i "$NAME" jq -r '.[] | "monitor \(.name) \(.width)x\(.height)"' +echo "exec env: $ENV" +echo "e.g.: docker exec $ENV $NAME hyprctl -j clients" +echo "logs: docker exec $NAME cat /var/log/rig/hyprland.log" diff --git a/desktop/tests/rig/hyprland/Dockerfile b/desktop/tests/rig/hyprland/Dockerfile new file mode 100644 index 0000000..a16396b --- /dev/null +++ b/desktop/tests/rig/hyprland/Dockerfile @@ -0,0 +1,17 @@ +# Headless Hyprland rig for agent automation tests (see hyprland.sh). +# wlrctl is AUR-only on Arch, so it is built from source in a throwaway stage. +FROM archlinux:latest AS wlrctl +RUN pacman -Syu --noconfirm --needed base-devel meson ninja git wayland wayland-protocols libxkbcommon scdoc \ + && git clone --depth 1 https://git.sr.ht/~brocellous/wlrctl /src \ + && cd /src && meson setup build --prefix=/usr && ninja -C build && DESTDIR=/out ninja -C build install + +FROM archlinux:latest +RUN pacman -Syu --noconfirm --needed \ + hyprland xdg-desktop-portal-hyprland grim wtype foot jq mesa seatd \ + dbus procps-ng file python ttf-dejavu \ + && pacman -Scc --noconfirm && rm -rf /var/cache/pacman/pkg/* +COPY --from=wlrctl /out/usr/ /usr/ +COPY hyprland.lua /root/.config/hypr/hyprland.lua +COPY rig-session /usr/local/bin/rig-session +RUN chmod 0755 /usr/local/bin/rig-session +CMD ["/usr/local/bin/rig-session"] diff --git a/desktop/tests/rig/hyprland/hyprland.lua b/desktop/tests/rig/hyprland/hyprland.lua new file mode 100644 index 0000000..e663b25 --- /dev/null +++ b/desktop/tests/rig/hyprland/hyprland.lua @@ -0,0 +1,9 @@ +-- Minimal Hyprland 0.56 (Lua) config for the headless w3bs rig. +hl.monitor({ output = "", mode = "1920x1080@60", position = "0x0", scale = 1 }) +hl.config({ + general = { gaps_in = 0, gaps_out = 0, border_size = 1, layout = "dwindle" }, + decoration = { rounding = 0, shadow = { enabled = false }, blur = { enabled = false } }, + animations = { enabled = false }, + misc = { force_default_wallpaper = 0, disable_hyprland_logo = true, disable_splash_rendering = true }, + input = { kb_layout = "us", follow_mouse = 1 }, +}) diff --git a/desktop/tests/rig/hyprland/rig-session b/desktop/tests/rig/hyprland/rig-session new file mode 100755 index 0000000..155780c --- /dev/null +++ b/desktop/tests/rig/hyprland/rig-session @@ -0,0 +1,41 @@ +#!/bin/sh +# Container entrypoint: seatd + D-Bus session + Hyprland (DRM backend) + one headless output. +set -eu +export XDG_RUNTIME_DIR=/run/user/0 +export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus +export LIBSEAT_BACKEND=seatd SEATD_VTBOUND=0 HYPRLAND_NO_SD_NOTIFY=1 +export AQ_DRM_DEVICES=${AQ_DRM_DEVICES:-$(ls /dev/dri/card* 2>/dev/null | head -1)} +export AQ_NO_KMS_REQUIREMENT=1 XDG_SESSION_TYPE=wayland XDG_CURRENT_DESKTOP=Hyprland +L=/var/log/rig; mkdir -p $L; mkdir -p -m 0700 $XDG_RUNTIME_DIR +[ -n "$AQ_DRM_DEVICES" ] || { echo "rig: no /dev/dri/card* passed in" >&2; exit 2; } + +seatd -g root >$L/seatd.log 2>&1 & +dbus-daemon --session --address=$DBUS_SESSION_BUS_ADDRESS --nofork --nopidfile >$L/dbus.log 2>&1 & +for i in $(seq 1 50); do [ -S /run/seatd.sock ] && [ -S $XDG_RUNTIME_DIR/bus ] && break; sleep 0.1; done + +# start-hyprland is the watchdog launcher (plain Hyprland pops a red "started without +# start-hyprland" banner into every screenshot). Root in a throwaway container: the flag skips +# only the privileges check. +start-hyprland -- --i-am-really-stupid >$L/hyprland.log 2>&1 & +HPID=$! +for i in $(seq 1 150); do + SIG=$(ls $XDG_RUNTIME_DIR/hypr 2>/dev/null | head -1) + [ -n "$SIG" ] && [ -S "$XDG_RUNTIME_DIR/hypr/$SIG/.socket.sock" ] && break + kill -0 $HPID 2>/dev/null || { echo "rig: Hyprland exited, see $L/hyprland.log" >&2; exit 1; } + sleep 0.1 +done +WAYLAND_DISPLAY=$(cd $XDG_RUNTIME_DIR && ls wayland-* | grep -v lock | head -1) +export HYPRLAND_INSTANCE_SIGNATURE=$SIG +# Every DRM connector is disconnected on a headless box, so give Hyprland a virtual output. +hyprctl -j monitors | jq -e 'length > 0' >/dev/null || hyprctl output create headless ${RIG_OUTPUT:-HEADLESS-1} >/dev/null +for i in $(seq 1 50); do hyprctl -j monitors | jq -e 'length > 0' >/dev/null 2>&1 && break; sleep 0.1; done + +cat > $XDG_RUNTIME_DIR/rig.env < { + const p = join(dir, e.name); + return e.isDirectory() ? walk(p) : e.isFile() ? [p] : []; + }); +} + +/** Every file of the pack with its SHA-256, as `sha256 path` lines under a version header. */ +export function deManifest() { + if (!existsSync(SHARE)) return null; + const version = readFileSync(join(SHARE, 'VERSION'), 'utf8').trim(); + const files = walk(SHARE) + .map((p) => relative(SHARE, p).split(sep).join('/')) + .sort() + .map((rel) => ({ rel, sum: createHash('sha256').update(readFileSync(join(SHARE, rel))).digest('hex') })); + return { version, files, text: `# W3BS DE files\n# version ${version}\n${files.map((f) => `${f.sum} ${f.rel}`).join('\n')}\n` }; +} + +export function mountDe(app) { + const manifest = deManifest(); + const known = new Set(manifest?.files.map((f) => f.rel) ?? []); + const cache = { 'Cache-Control': 'public, max-age=300' }; + app.get('/de/install.sh', (_req, res) => { + const path = join(DE, 'install.sh'); + if (!existsSync(path)) throw new W3bsError('NOT_FOUND', 'Installer not published yet.', 404); + res.type('text/x-shellscript; charset=utf-8').set(cache).send(readFileSync(path)); + }); + app.get('/de/manifest.txt', (_req, res) => { + if (!manifest) throw new W3bsError('NOT_FOUND', 'W3BS DE is not published yet.', 404); + res.type('text/plain; charset=utf-8').set(cache).send(manifest.text); + }); + // Only paths listed in the manifest are served, so nothing outside de/share is reachable. + app.get(/^\/de\/files\/(.+)$/, (req, res) => { + const rel = req.params[0]; + if (!known.has(rel)) throw new W3bsError('NOT_FOUND', 'No such W3BS DE file.', 404); + const ext = rel.slice(rel.lastIndexOf('.')); + res.type(TYPES[ext] ?? 'text/plain; charset=utf-8').set(cache).send(readFileSync(join(SHARE, rel))); + }); + app.get('/desktop-environment', (_req, res) => res.redirect(301, '/de')); +} + +export function deVersion() { + try { + return readFileSync(join(SHARE, 'VERSION'), 'utf8').trim(); + } catch { + return null; + } +} diff --git a/src/server.mjs b/src/server.mjs index aa3c6bd..cdd53f6 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -14,6 +14,7 @@ import { descriptor } from '@profullstack/footer'; import { createScanner } from './scan.mjs'; import { mountAccounts, OPEN_CORS, bearerOf } from './auth-routes.mjs'; import { mountFleet } from './fleet-routes.mjs'; +import { mountDe } from './de-routes.mjs'; import { mountSocial, socialConfig } from './social-routes.mjs'; import { createMailer } from './mail.mjs'; import { createHostedTraining } from '../training/hosted.mjs'; @@ -347,7 +348,7 @@ export function createApp({ res .type('application/xml') .send( - `${['/mission', '/pricing', '/packages', '/packages/publish', '/specs', '/browse', '/prompts', '/desktop', '/training', '/tasks', '/workflows', '/os', '/ab-testing', '/social', '/governance', '/developers', '/signup', '/terms', '/privacy'].map((path) => `${origin}${path}`).join('')}`, + `${['/mission', '/pricing', '/packages', '/packages/publish', '/specs', '/browse', '/prompts', '/desktop', '/de', '/training', '/tasks', '/workflows', '/os', '/ab-testing', '/social', '/governance', '/developers', '/signup', '/terms', '/privacy'].map((path) => `${origin}${path}`).join('')}`, ), ); app.get('/docs/:file', (req, res) => { @@ -369,6 +370,8 @@ export function createApp({ res.type(type).set('Cache-Control', 'public, max-age=300').send(readFileSync(path)); }); } + // W3BS DE: installer, file manifest and files (w3bs.org/de). + mountDe(app); // W3BS Training installers (the recorder in the desktop layer hands recordings to it). for (const [route, file, type] of [ ['/training/install.sh', 'install.sh', 'text/x-shellscript; charset=utf-8'], diff --git a/src/site.mjs b/src/site.mjs index 15425c2..a9fde1e 100644 --- a/src/site.mjs +++ b/src/site.mjs @@ -80,6 +80,7 @@ const NAV_PRIMARY = [ ['/packages', 'Packages'], ['/os', 'OS'], ['/desktop', 'Desktop'], + ['/de', 'DE'], ['/training', 'Training'], ['/tasks', 'Tasks'], ['/workflows', 'Workflows'], @@ -391,13 +392,9 @@ const desktopPlatforms = [ ['First', 'GNOME', 'Top-panel extension and settings app', 'Installable, testing'], ['Next', 'Windows', 'Taskbar notification-area icon', 'Installable, testing'], ['Next', 'macOS', 'Menu-bar item', 'Installable, testing'], + ['Next', 'Hyprland (and W3BS DE)', 'Tray in waybar; bar indicator in W3BS DE', 'Installable, testing'], ['Next', 'Cinnamon, Xfce, MATE, LXQt', 'Tray or panel integration', 'Planned'], - [ - 'Expansion', - 'Vela, Sway, Hyprland and others', - 'Capability-based compositor adapters', - 'Planned', - ], + ['Expansion', 'Vela, Sway and others', 'Capability-based compositor adapters', 'Planned'], ]; const desktopArchitecture = `Claude Code / OpenCode / Codex / compatible clients | @@ -477,6 +474,54 @@ curl -fsSL https://w3bs.org/desktop/install.sh | sh -s -- --uninstall # Windows (PowerShell) & ([scriptblock]::Create((irm https://w3bs.org/desktop/install.ps1))) -Uninstall`; +const DE_INSTALL = 'curl -fsSL https://w3bs.org/de/install.sh | sh'; +const deDescription = + 'W3BS DE: a minimal, agent-friendly Wayland desktop built on Hyprland, with the Agentic Desktop Layer built in and agent access off until you turn it on.'; +const deKeys = [ + ['SUPER + SPACE', 'Launcher'], + ['SUPER + RETURN', 'Terminal'], + ['SUPER + W', 'Close the window'], + ['SUPER + 1 … 9', 'Workspaces (add SHIFT to move the window)'], + ['SUPER + arrows', 'Focus (add SHIFT to swap)'], + ['SUPER + T / F', 'Float or tile / full screen'], + ['SUPER + A', 'Agent control panel'], + ['SUPER + SHIFT + A', 'The agent workspace (a scratch space agents can use out of your way)'], + ['SUPER + ALT + ESCAPE', 'Turn agent access off, from anywhere'], + ['SUPER + N', 'Notifications'], + ['SUPER + ESCAPE', 'Lock'], + ['PRINT', 'Screenshot a region to the clipboard'], +]; +const deDistros = [ + ['Arch, EndeavourOS, CachyOS', 'pacman (extra)', 'Hyprland 0.56'], + ['Fedora', 'COPR lionheartp/Hyprland (the one the Hyprland wiki lists)', 'Hyprland 0.56'], + ['Debian', 'trixie-backports, forky, sid', '0.55 and later'], + ['Ubuntu', '26.10 and later (26.04 ships 0.53: hyprlang defaults)', '0.56'], + ['Alpine, postmarketOS', 'community (edge)', '0.54: hyprlang defaults'], + ['openSUSE Tumbleweed', 'zypper', 'current'], + ['NixOS', 'the installer prints the configuration.nix lines', 'current'], +]; +function dePage() { + const shot = existsSync(resolve(ROOT, 'public', 'de-desktop.webp')) + ? `
W3BS DE: the w3bs bar at the top with workspaces, the window title, the agent indicator and the clock, two terminals tiled side by side on the dark w3bs wallpaper.
W3BS DE on a test rig: the bar, two tiled terminals and the wallpaper. The agent indicator in the bar says whether agent access is off, on, or which agent is driving.
` + : ''; + return `
W3BS DE · BUILT ON HYPRLAND · WAYLAND

A quiet desktop.
Agents welcome.
Your switch.

W3BS DE is a minimal Wayland desktop for people who work with agents. Hyprland underneath, one bar, one launcher, one notification center, and the Agentic Desktop Layer built in: your agents can see and use this desktop when, and only when, you turn agent access on.

LINUX (X86_64, ARM64)
${e(DE_INSTALL)}

It installs Hyprland and a handful of small tools with your package manager (it asks first), the W3BS DE defaults in your home directory, a "W3BS DE" entry on your login screen, and the Agentic Desktop Layer. Read it first: de/install.sh.

Release status. 0.1, new. The agent backend for Hyprland (windows and layout over Hyprland's IPC, input through the Wayland virtual pointer and keyboard, captures through grim) ships in the Agentic Desktop Layer 0.8. Verified on Hyprland 0.56.2 with these defaults loaded: windows, captures, typing (every US key, accents, CJK) exactly, pointer, clicks and workspace shortcuts. Both config formats pass Hyprland's own --verify-config. W3BS DE is an independent project built on Hyprland; it is not made by or affiliated with the Hyprland project.
+ ${shot ? `
${shot}
` : ''} +
WHAT IT IS

Small on purpose.

Every piece is something you could name and replace. Nothing runs that you did not ask for.

01 / MINIMAL

Hyprland and seven small tools.

waybar for the bar, fuzzel to launch, swaync for notifications, hyprlock and hypridle, swaybg for the wallpaper, foot as the terminal. No blur, no shadows, short animations, the w3bs palette.

02 / AGENT-FRIENDLY

Built for the observe, act, observe loop.

Predictable tiling, windows that open where they say, real window titles, and a desktop layer that types any Unicode character exactly. Agents see what you see.

03 / YOURS

Your settings survive updates.

The defaults live in ~/.local/share/w3bs-de; your changes go in ~/.config/w3bs-de/hypr/user.lua and load last. W3BS DE uses its own config, so an existing Hyprland setup is left alone.

+
AGENTS

Agents get a desktop.
You keep the switch.

The Agentic Desktop Layer starts with the session, Disabled. Everything it does on any desktop, it does here, with a backend made for Hyprland.

THE BAR

Always know who is driving.

The agent indicator reads off, on, which agent is driving, or "agent needs you". Click it for the control app; right-click turns agent access off. SUPER + ALT + ESCAPE does the same from anywhere.

ASKS FIRST

Drive prompts and human steps.

An agent asks before it drives, and hands you anything only a person should do: CAPTCHAs, sign-in codes, passwords, payments. They pop up in the notification center with their buttons.

OUT OF YOUR WAY

An agent workspace.

SUPER + SHIFT + A shows a scratch workspace where agents can work while you use the others. Agent mode (w3bs-de agent-mode on) keeps the screen unlocked while agents work unattended; leave it off otherwise.

+
THE KEYS

Twelve to learn.

SUPER is the Windows or Command key. Add your own in user.lua with w3bs.bind("SUPER + E", "Files", "nautilus").

${deKeys.map(([k, d]) => ``).join('')}
Key bindings
KeysDoes
${e(k)}${e(d)}
+
WHERE IT RUNS

Your distribution's Hyprland.

W3BS DE installs Hyprland from your distribution; it does not build or bundle it. Hyprland 0.55 and later use the Lua defaults; older releases get the same desktop from hyprlang defaults.

${deDistros.map(([d, f, v]) => ``).join('')}
Distributions
DistributionHyprland fromVersion
${e(d)}${e(f)}${e(v)}
+
EVERYTHING ELSE

One command.

The w3bs-de command runs the session and its helpers.

${e(`w3bs-de session            start the desktop (what the login screen runs)
+w3bs-de doctor             check what the desktop and its agent layer need
+w3bs-de agent-mode on|off  keep the screen unlocked while agents work unattended
+w3bs-de wallpaper [FILE]   set (or reset) the wallpaper
+w3bs-de screenshot region|screen
+w3bs-de update             fetch the latest defaults (your settings are kept)
+
+Install options: --yes, --no-packages, --no-agents, --no-session`)}
Uninstall
${e(`rm -rf ~/.local/share/w3bs-de ~/.local/bin/w3bs-de
+rm -rf ~/.config/w3bs-de                      # your settings
+sudo rm -f /usr/share/wayland-sessions/w3bs-de.desktop /usr/local/bin/w3bs-de
+# Hyprland and the tools stay installed; remove them with your package manager.`)}
`; +} function desktopPage() { const prd = readFileSync(resolve(ROOT, 'docs', 'agentic-desktop-layer-prd.md'), 'utf8'); const shots = `
The control app with agent access Enabled: the active agent and its task, Disable agent access, Stop all agent activity and Quit and disable buttons, the emergency stop shortcut, a pending approval to type into the Kate editor, and three agents with their permission presets.
The control app, opened from the tray. Status, the big switches, approvals and every agent's access on one page.
Changing an agent's access in the control app: Observe, Restricted, Broad desktop or Nothing, and the list of apps a restricted agent may use.
Change what an agent may do: observe only, restricted to named apps, the whole desktop, or nothing.
The control app on a narrow screen: Enabled status and full-width Disable, Stop and Quit buttons.
It stays usable in a narrow window.

Real screenshots of the control app running against the layer's built-in demo desktop, so the agents and tasks are sample data. The app only answers on 127.0.0.1 and signs in with a one-time link from the tray or w3bs desktop control --open.

`; @@ -714,6 +759,10 @@ export async function renderPage({ path, host, query, store, origin, browser }) description = 'Teach agents by doing the task yourself: record it from the Agentic Desktop Layer, review it, and let only the agents you choose retrieve the approved examples over MCP.'; content = trainingPage(); + } else if (path === '/de') { + title = 'W3BS DE'; + description = deDescription; + content = dePage(); } else if (path === '/desktop') { title = 'Agentic Desktop Layer'; description = desktopDescription;