Panel appearance now applies to the whole panel, with seven styles: Floating, Capsules, Notch, Edge to edge, Fused islands, Adaptive and Morph. - Share composition, geometry, and motion through the renderer-independent `ferese-panel` crate, including native materials, input regions, and Settings previews. - Add the Morph hub with media, calendar, notifications, controls, workspace previews, hover feedback, and attached mode. Keep recording and low-battery status visible and respect window clearance. - Cache workspace preview geometry, draw thumbnails directly, centralize Morph transitions, and keep rendering settings local to each shell instance. - Put Record Screen in the Morph header and show the display control only when an external monitor is connected. Update panel documentation, the desktop screenshot, and native check style names. Local validation included 660 compositor tests, 191 shell tests, isolated preview-cache checks, GPU Morph captures at 1×/1.25×/1.5×, formatting, and Clippy with warnings denied. The final header change passed all 23 Morph tests. Release workflow jobs are skipped for this PR.
165 lines
8.6 KiB
Python
165 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Run the current shell and Settings in a private nested desktop."""
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def request(endpoint, command, args=None):
|
|
data = json.dumps({"version": 1, "id": 1, "type": "command", "command": command, "args": args or {}}).encode()
|
|
with socket.socket(socket.AF_UNIX) as connection:
|
|
connection.settimeout(2)
|
|
connection.connect(str(endpoint))
|
|
connection.sendall(struct.pack(">I", len(data)) + data)
|
|
def receive(count):
|
|
result = bytearray()
|
|
while len(result) < count:
|
|
part = connection.recv(count - len(result))
|
|
if not part:
|
|
raise ConnectionError("Preview compositor disconnected")
|
|
result.extend(part)
|
|
return bytes(result)
|
|
length = struct.unpack(">I", receive(4))[0]
|
|
if length > 4 * 1024 * 1024:
|
|
raise ValueError("Oversized preview response")
|
|
result = json.loads(receive(length))
|
|
if "error" in result:
|
|
raise RuntimeError(result["error"])
|
|
return result["result"]
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--no-build", action="store_true", help="Use existing binaries")
|
|
parser.add_argument("--debug", action="store_true", help="Use debug builds instead of optimized release builds")
|
|
parser.add_argument("--style", default="floating", help="Initial panel style")
|
|
args = parser.parse_args()
|
|
profile = "debug" if args.debug else "release"
|
|
if not args.no_build:
|
|
command = ["cargo", "build", "-p", "ferese", "-p", "ferese-shell", "-p", "ferese-settings"]
|
|
if not args.debug:
|
|
command.append("--release")
|
|
subprocess.run(command, cwd=REPO, check=True)
|
|
binaries = {name: REPO / "target" / profile / name for name in ("ferese", "ferese-shell", "ferese-settings")}
|
|
if any(not binary.is_file() for binary in binaries.values()):
|
|
parser.error("Build the compositor, shell and Settings first")
|
|
display = Path(os.environ.get("WAYLAND_DISPLAY", ""))
|
|
if not os.environ.get("WAYLAND_DISPLAY"):
|
|
parser.error("Run this preview inside a Wayland session")
|
|
if not display.is_absolute():
|
|
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
|
|
children = []
|
|
with tempfile.TemporaryDirectory(prefix="ferese-panel-preview-") as temporary:
|
|
root = Path(temporary)
|
|
# Keep sibling validation and apps opened from the shell on this build,
|
|
# even if Cargo replaces target/release while the preview is running.
|
|
preview_bin = root / "bin"
|
|
preview_bin.mkdir()
|
|
for name, binary in binaries.items():
|
|
shutil.copy2(binary, preview_bin / name)
|
|
for name in ("ferese-record", "ferese-lock", "ferese-launcher", "feresectl"):
|
|
binary = REPO / "target" / profile / name
|
|
if binary.is_file():
|
|
shutil.copy2(binary, preview_bin / name)
|
|
binaries = {name: preview_bin / name for name in binaries}
|
|
runtime = root / "runtime"
|
|
runtime.mkdir(mode=0o700)
|
|
config = root / "config/ferese"
|
|
config.mkdir(parents=True)
|
|
# Keep the complete composition; an authored panel with no groups is empty.
|
|
source = (REPO / "packaging/config.kdl").read_text().replace('style "flat"', 'style ' + json.dumps(args.style), 1)
|
|
if args.style == "morph":
|
|
source = source.replace('border #false', 'border #true', 1)
|
|
source = re.sub(r'keybinding-guide\s+#true', 'keybinding-guide #false', source)
|
|
config_path = config / "config.kdl"
|
|
config_path.write_text(source)
|
|
subprocess.run([str(binaries["ferese"]), "--check-config", str(config_path)], check=True)
|
|
env = {key: value for key, value in os.environ.items() if not key.startswith("FERESE_")}
|
|
env["PATH"] = str(preview_bin) + os.pathsep + env.get("PATH", os.defpath)
|
|
env.update(XDG_RUNTIME_DIR=str(runtime), WAYLAND_DISPLAY=str(display), XDG_CONFIG_HOME=str(root / "config"),
|
|
XDG_STATE_HOME=str(root / "state"), XDG_CACHE_HOME=str(root / "cache"),
|
|
XDG_DATA_HOME=str(root / "data"), FERESE_ENABLE_SCREENCOPY="1",
|
|
FERESE_COMPOSITOR_WALLPAPER="1", WINIT_UNIX_BACKEND="wayland", ICED_BACKEND="wgpu", RUST_LOG="ferese=info")
|
|
env.pop("WAYLAND_SOCKET", None)
|
|
bus_config = root / "bus.conf"
|
|
bus_config.write_text('<busconfig><type>session</type><listen>unix:tmpdir=/tmp</listen>'
|
|
'<policy context="default"><allow send_destination="*"/>'
|
|
'<allow receive_sender="*"/><allow own="*"/></policy></busconfig>')
|
|
launcher = root / "launch.sh"
|
|
launcher.write_text('#!/bin/bash\nunset DISPLAY\nexport WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY}"\n'
|
|
'env -u WAYLAND_SOCKET -u FERESE_SHELL_CONTROL_SOCKET -u DISPLAY '
|
|
'WINIT_UNIX_BACKEND=wayland '
|
|
+ shlex.quote(str(binaries["ferese-settings"])) + ' --page bar &\nexec '
|
|
+ shlex.quote(str(binaries["ferese-shell"])) + '\n')
|
|
launcher.chmod(0o700)
|
|
log_path = root / "session.log"
|
|
def stop(_signal, _frame):
|
|
raise KeyboardInterrupt
|
|
signal.signal(signal.SIGTERM, stop)
|
|
try:
|
|
with log_path.open("w") as log:
|
|
bus = subprocess.Popen(["dbus-daemon", "--config-file=" + str(bus_config), "--nofork", "--print-address=1"],
|
|
env=env, stdout=subprocess.PIPE, stderr=log, text=True, start_new_session=True)
|
|
children.append(bus)
|
|
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
|
|
compositor = subprocess.Popen([str(binaries["ferese"]), "--backend", "nested", "--grant-effects",
|
|
"--grant-shell-control", "--", str(launcher)],
|
|
env=env, stdout=log, stderr=log, start_new_session=True)
|
|
children.append(compositor)
|
|
deadline = time.monotonic() + 30
|
|
readiness = "Waiting for the compositor socket"
|
|
while True:
|
|
endpoints = list(runtime.glob("ferese/instances/*/control.sock"))
|
|
trace = log_path.read_text()
|
|
child = re.search(r'spawned Wayland client.*pid=(\d+)', trace)
|
|
if compositor.poll() is not None or time.monotonic() >= deadline:
|
|
raise RuntimeError("Preview did not create its shell and Settings: " + readiness + "\n" + trace[-8000:])
|
|
if endpoints and child:
|
|
try:
|
|
panel = request(endpoints[0], "has-client-surfaces", {"pid": int(child.group(1))})
|
|
# The private socketpair has the compositor's peer credentials.
|
|
# Wallpaper is disabled, so this client can only own shell surfaces.
|
|
panel = panel or request(endpoints[0], "has-client-surfaces", {"pid": compositor.pid})
|
|
windows = request(endpoints[0], "get-windows")
|
|
settings = any(window["mapped"] and window["app_id"] == "dev.ferese.Settings"
|
|
for window in windows)
|
|
readiness = f"shell surfaces={panel}; windows={windows}"
|
|
if panel and settings:
|
|
break
|
|
except (OSError, ConnectionError):
|
|
pass
|
|
time.sleep(.05)
|
|
print(f"Panel preview ready ({profile} build). Changes apply only inside this window.\nConfig: {config_path}\nLog: {log_path}", flush=True)
|
|
compositor.wait()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
for child in reversed(children):
|
|
try:
|
|
os.killpg(child.pid, signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
pass
|
|
try:
|
|
child.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
os.killpg(child.pid, signal.SIGKILL)
|
|
child.wait(timeout=5)
|
|
if child.stdout is not None:
|
|
child.stdout.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|