diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 524d6ce..1e20dad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ jobs: - name: Remote streams run: python3 test/stream.py && python3 test/windows_display.py && lua test/stream.lua - name: Scenes and content - run: python3 test/scenes.py && lua test/scenes.lua && node test/content.js + run: python3 test/scenes.py && python3 test/apps.py && lua test/scenes.lua && node test/content.js - name: Stream quality and reconnect run: python3 test/quality.py && lua test/quality.lua - name: Managed layout browsing diff --git a/README.md b/README.md index 80dd043..6eaa1fa 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ everything else in place, and is the one to run again after every update: - the engine and bridge into `~/.config/hypr/`, and the two shipped layouts into `~/.config/hypr/layouts/` (a layout that already exists is left alone) - `hypertile-ctl` into `~/.local/bin/` -- the session recovery service, started by the layout loader +- the session recovery and independent Scenes services, started by the layout loader - a `require("hypr.hypertile-layouts")` line in `hyprland.lua` - the bar widget after the workspaces (skipped when it is already on the bar) - a **Layouts** entry in the `SUPER+SPACE` menu (`--no-menu` skips it) @@ -369,20 +369,16 @@ installing: `HYPERTILE_SRC=$PWD bin/hypertile-ctl list`. ## Remote desktops -Remote desktops can be assigned to named zones with `hypertile-ctl stream`. -Use `SUPER+SHIFT+Arrow` to swap a ready stream with a neighboring window; its -zone reservation and saved assignment move with it. +Scenes can launch or reuse installed apps in named zones, including each +computer's Remote Desktops launcher. Placement happens once; subsequent window +moves and closes stay under your control. The independent Scenes service does +not own remote connections or host display settings. -The overlay’s **Scenes** tab shows what each zone holds and assigns -computers/profiles, local apps, and Empty to zones. Save and restore workspace -scenes there or with `hypertile-ctl scene`. -See [scenes and content](docs/SCENES.md) for the UI, CLI, and stable zone references. -See [remote desktop setup and recovery](docs/STREAMS.md) for computer profiles, -the Mac display adapter, and the Windows externally managed profile. - -[Performance reports](docs/STREAM-QUALITY.md) measure reconnect timing, retain -Moonlight decoder summaries, and record readability at each view size. Use -Reconnect from the Scenes tab's zone controls. +Use the overlay’s **Scenes** tab or `hypertile-ctl scene` to assign apps, local +windows, or Empty, then save the arrangement. See [scenes and content](docs/SCENES.md) +for setup, migration from legacy stream sources, and recovery behavior. +The [legacy stream tools](docs/STREAMS.md) remain available to restore existing +host journals while migrating to Remote Desktops. ## License diff --git a/bin/hypertile-ctl b/bin/hypertile-ctl index de67919..6afa76e 100755 --- a/bin/hypertile-ctl +++ b/bin/hypertile-ctl @@ -449,12 +449,12 @@ end local argv = { ... } local cmd = table.remove(argv, 1) if cmd == "stream" or cmd == "computers" or cmd == "scene" then - local command = src and src ~= "" and (src .. "/bin/hypertile-stream") - or ((os.getenv("HOME") or "") .. "/.local/bin/hypertile-stream") + local entry = cmd == "scene" and "hypertile-scenes" or "hypertile-stream" + local command = src and src ~= "" and (src .. "/bin/" .. entry) + or ((os.getenv("HOME") or "") .. "/.local/bin/" .. entry) local words = { command } if cmd == "computers" then words[#words + 1] = "computers" end if cmd == "scene" then - words[#words + 1] = "scene" if #argv == 0 then argv[1] = "current" end end if cmd == "stream" and #argv == 0 then argv[1] = "status" end diff --git a/bin/hypertile-scenes b/bin/hypertile-scenes new file mode 100755 index 0000000..62155f4 --- /dev/null +++ b/bin/hypertile-scenes @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +"""Entry point for the installed scene service (or HYPERTILE_SRC).""" +import os +from pathlib import Path +import runpy +import sys + +root = Path(os.environ["HYPERTILE_SRC"]) if os.environ.get("HYPERTILE_SRC") else Path( + os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share") / "hypertile" +sys.path.insert(0, str(root / "session")) +sys.path.insert(0, str(root / "stream")) +runpy.run_path(str(root / "stream/scene_service.py"), run_name="__main__") diff --git a/dev b/dev index 3ca6cd9..be91ebb 100755 --- a/dev +++ b/dev @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Link and apply a Hypertile checkout to an existing Omarchy installation.""" import argparse -from contextlib import contextmanager, nullcontext +from contextlib import contextmanager, nullcontext, ExitStack import fcntl import hashlib import json @@ -56,7 +56,7 @@ def groups(): return { "lua": sorted(ROOT.glob("hypertile*.lua")), "cli": [ROOT / "bin/hypertile-ctl"], - "session": [ROOT / "bin/hypertile-session", *sorted((ROOT / "bin").glob("hypertile-stream")), + "session": [ROOT / "bin/hypertile-session", *sorted((ROOT / "bin").glob("hypertile-stream")), *sorted((ROOT / "bin").glob("hypertile-scenes")), *sorted((ROOT / "session").glob("*.py")), *sorted((ROOT / "stream").glob("*.py")), *sorted((ROOT / "stream/windows").glob("*.ps1")), *sorted((ROOT / "stream/windows").glob("*.cs"))], "shell": [ROOT / "manifest.json", *sorted(p for p in (ROOT / "plugin").rglob("*") if p.is_file())], @@ -166,44 +166,62 @@ def wait_for(probe, description, seconds=10): @contextmanager def stopped_session(): - stream_lock = None - if (BIN / "hypertile-stream").exists(): - path = STATE / "streams/writer.lock" + with ExitStack() as stack: + scene_lock = None + if (BIN / "hypertile-scenes").exists(): + path = STATE / "scenes/writer.lock" + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + scene_lock = stack.enter_context(path.open("a")) + def scene_available(): + try: + fcntl.flock(scene_lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + return True + except BlockingIOError: + return False + if not scene_available(): + run(BIN / "hypertile-scenes", "stop", timeout=10) + wait_for(scene_available, "scene service to stop") + stream_lock = None + if (BIN / "hypertile-stream").exists(): + path = STATE / "streams/writer.lock" + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + stream_lock = stack.enter_context(path.open("a")) + def stream_available(): + try: + # Exclude the legacy writer while permitting Remote Desktops + # to keep its shared migration guard throughout deployment. + fcntl.flock(stream_lock, fcntl.LOCK_SH | fcntl.LOCK_NB) + return True + except BlockingIOError: + return False + if not stream_available(): + status = json.loads(run(BIN / "hypertile-stream", "status", "--json", timeout=3).stdout) + if status.get("instance") != ENV.get("HYPRLAND_INSTANCE_SIGNATURE"): + raise RuntimeError("legacy stream controller belongs to another compositor") + if any(r.get("desired") or r.get("journal") for r in status.get("computers", [])): + raise RuntimeError("disconnect/restore legacy Hypertile streams before applying runtime changes") + run(BIN / "hypertile-stream", "stop", timeout=55) + wait_for(stream_available, "stream controller to stop") + path = STATE / "sessions/writer.lock" path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - stream_lock = path.open("a") - def stream_available(): - try: - fcntl.flock(stream_lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - return True - except BlockingIOError: - return False - if not stream_available(): - run(BIN / "hypertile-stream", "stop", timeout=55) - wait_for(stream_available, "stream controller to stop") - path = STATE / "sessions/writer.lock" - path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - with path.open("a") as lock: - def available(): - try: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - return True - except BlockingIOError: - return False - if not available(): - status = session_status() - if not status: - raise RuntimeError("session writer is busy but not responding; no files were changed") - if status.get("instance") != ENV.get("HYPRLAND_INSTANCE_SIGNATURE"): - raise RuntimeError("session service belongs to another compositor; use its terminal or --instance") - run(BIN / "hypertile-session", "stop") - wait_for(available, "session service to stop") - # Hold the writer lock across installation/reload. The loader cannot - # start a watcher against a half-updated set of runtime files. - try: + with path.open("a") as lock: + def available(): + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + return True + except BlockingIOError: + return False + if not available(): + status = session_status() + if not status: + raise RuntimeError("session writer is busy but not responding; no files were changed") + if status.get("instance") != ENV.get("HYPRLAND_INSTANCE_SIGNATURE"): + raise RuntimeError("session service belongs to another compositor; use its terminal or --instance") + run(BIN / "hypertile-session", "stop") + wait_for(available, "session service to stop") + # Hold the writer lock across installation/reload. The loader cannot + # start a watcher against a half-updated set of runtime files. yield - finally: - if stream_lock: - stream_lock.close() def lua_string(value): @@ -223,8 +241,8 @@ def reload_lua(): def start_session(): - if (BIN / "hypertile-stream").exists(): - command = shlex.join(["env", "-u", "HYPERTILE_SRC", str(BIN / "hypertile-stream"), "daemon"]) + if (BIN / "hypertile-scenes").exists(): + command = shlex.join(["env", "-u", "HYPERTILE_SRC", str(BIN / "hypertile-scenes"), "daemon"]) run("hyprctl", "eval", f"hl.exec_cmd({lua_string(command)})") settings = read_json(CONFIG / "hypertile/session.json", {}) if settings.get("enabled", True) is False: diff --git a/docs/SCENES.md b/docs/SCENES.md index 0a649af..b278d1f 100644 --- a/docs/SCENES.md +++ b/docs/SCENES.md @@ -1,167 +1,174 @@ # Scenes and content -A scene saves a workspace's layout and the content assigned to its zones. It can -combine local windows, paired remote desktops with named profiles, and Empty. -The stream controller applies the scene, owns its connections, and records -unfinished work for recovery. See [remote desktop setup](STREAMS.md) first to -configure and pair computers. +A scene saves a workspace's layout and the apps assigned to its zones. Choose +an installed app, an already open local window, normal fill order, or Empty. +Scenes launches or reuses an app window and places it once. You can then move, +resize, float, fullscreen, or close it without Scenes pulling it back or +opening another copy. + +Remote Desktops is an ordinary app in this model. It owns Moonlight/Sunshine, +connection profiles, reconnects, and host display restoration. Hypertile owns +layout and initial placement. No stream controller or computers.json is needed +for app scenes. ## Overlay -Open **Super+Alt+L** and switch the rail to its **Scenes** tab. The header -names the workspace's scene (or *No scene*) with its state; **Save scene…** -stores the current layout and content under a name, and **Restore previous** -puts back what the workspace had before the scene. **Saved scenes** lists every -scene with **Apply**; the applied one reads *Applied*, and ✕ deletes a saved -definition after a confirmation (nothing on the workspace changes). - -**Content** lists the zones in fill order with what each one holds: local -windows by fill order, *Empty*, one app, or a computer and profile with its -state. Click a row or a zone on the screen, or move with Tab and the arrows. -The **Zone** section shows the selected zone; for a remote desktop it offers -Focus, Disconnect and Reconnect (Retry and Restore display when they apply), -and **More controls** holds capture toggling, clipboard typing where supported, -Moonlight statistics, focusing a local window, the Performance panel and the -raw status. **Change to** puts something else in the zone: Local windows, -Empty, a computer's profile (its non-default traits are shown beside it), or an -open app. Choosing a computer already on this workspace moves its assignment. -Choosing another profile reconnects that computer after restoring the old -profile's managed display settings. - -Local app assignments use one matching tiled window on that workspace. Open the -app and Retry if none exists. If several match, resolve the ambiguity first; -Hypertile does not guess, launch another copy, or move a window from elsewhere. -Unassigned zones continue to use the layout's normal fill and application rules. -At least one fill/cycle zone must remain available for local windows. - -Changes and source swaps mark the current scene modified; saved definitions -change only when explicitly saved (**Save** writes a named scene back). Restore -previous returns to the layout and content from before the first scene -application in this sequence. A later scene replaces pending work from the -earlier one; compatible ready streams retain their client process. Failed -sources remain visible while local content can finish applying. - -Browsing under the Layouts tab moves the live windows, including connected -remote desktops. Scene assignments stay on the committed layout while the -other layout is previewed. Closing the overlay or returning to Scenes restores -that layout without reconnecting streams or changing saved scenes. An abandoned -preview expires after ten seconds without its overlay heartbeat; restarting the -controller also restores it. Session recovery retains its last committed -checkpoint while a preview is active. Using another layout there asks first: -the assignments are replaced with local content on the chosen layout (streams -disconnect, local apps stay open). Layout editing takes effect on Save. Deleted -referenced zones require choosing replacements; they are not reassigned by -their position in the layout. - -Ordinary scene changes do not take focus. Focus, clipboard, statistics, and -capture controls are explicit interactions with the selected remote desktop and -close the overlay. +Open **Super+Alt+L**, switch to **Scenes**, and select a zone. Under **Change to**, +**Installed apps** offers desktop entries with a known window identity. Select +**MacBook (Remote Desktop)** to launch or reuse that computer in the selected +zone. Install its launcher in Remote Desktops first. Each computer's launcher +uses that computer's default profile. + +Applications declaring `StartupWMClass` are available immediately. For other +apps, an open window whose class equals the desktop ID without `.desktop` +provides the identity. Apps without either can be configured through the CLI +with an explicit class and optional exact title. **Open apps** retains the +previous local-only behavior: pin one matching tiled window already on this +workspace, without launching it. + +**Save scene…** stores the current definition. **Apply** requests that saved +arrangement again, including apps you moved or closed. **Retry** explicitly +rechecks placement and may retry a failed/timed-out launch. Closing an app or +moving it yourself leaves its source marked *Closed* or *Moved*. Changes to the +saved definition happen only when you save. + +**Restore previous** restores the prior layout/content and eligible app pins. +It leaves apps open and does not move departed windows back to their original +workspace. Changing scenes or cancelling an in-progress scene stops pending +placement; an app already launched may still open normally. Empty/fill behavior +and the layout's application rules continue to apply to other windows. + +Layout browsing previews the geometry and restores the committed layout when +you leave the preview. The lease expires after ten seconds without a heartbeat. +Session capture waits until the preview ends. Choosing a different layout +replaces the assignments with local content; open apps keep running. ## CLI ```bash -hypertile-ctl scene content --zone right --type stream --computer macbook --profile desktop +hypertile-ctl scene catalog --json +hypertile-ctl scene content --zone right --type app --desktop-id remote-desktops-macbook.desktop hypertile-ctl scene content --zone left --type empty hypertile-ctl scene content --zone center --type local --app-class org.example.Editor hypertile-ctl scene save work -hypertile-ctl scene list --json hypertile-ctl scene apply work --workspace 1 hypertile-ctl scene current --workspace 1 --json hypertile-ctl scene retry --workspace 1 hypertile-ctl scene restore --workspace 1 ``` -Use names from your actual layout and app classes from `hypertile-ctl windows ---json`. Scenes currently target existing numbered workspaces. Omit -`--workspace` to use the active workspace. A computer can occupy one zone only; -explicitly disconnect it before assigning it on another workspace. - -`apply` accepts work asynchronously. Check `current` for ready, connecting, -partial, or needs-attention. Retry rechecks pending content. An explicit stream -Disconnect suppresses further automatic reconnects from that scene. Apply the -scene again to request those connections again. `cancel` is an alias for -`restore`. `remove NAME` removes a saved definition without stopping its active -connections. `scene layout NAME` starts a scene containing local windows using -that layout. - -Switch a connected computer's profile without manually sequencing teardown: +For an app without declared identity, add `--app-class org.example.App` and, +when its class is shared, `--app-title 'Exact window title'`. Classes and titles +are literal strings, not regexes. Use `hypertile-ctl windows --json` to inspect +windows. Desktop IDs resolve through the XDG application directories, with +user entries taking precedence. Arbitrary desktop paths and stored shell +commands are not accepted. `gio launch` handles the installed desktop file. -```bash -hypertile-ctl stream profile macbook --profile desktop-capture -hypertile-ctl stream stats macbook -hypertile-ctl stream input-release macbook -hypertile-ctl stream clipboard work-laptop -``` +Scenes uses existing numbered workspaces. Omit `--workspace` for the current +one. An explicit assignment elsewhere supersedes an older pending assignment +of the same app. Applying a scene accepts work asynchronously; inspect `current` +for progress. A missing app window times out after 45 seconds. Ambiguous matches +require closing extras or narrowing the title; no arbitrary window is selected. +An interrupted launch is not automatically submitted again after service restart. -`input-release` toggles Moonlight's capture; it is not an idempotent release. -For Mac Command shortcuts, select a `system_keys: always` profile, focus the -stream, then enter it with the pointer or activate Toggle capture. Focusing a -newly opened client alone may leave capture inactive. -Clipboard typing sends the local text clipboard into the host's focused app. -It is neither clipboard synchronization nor file transfer. Hypertile sends the -stock Moonlight shortcut without reading or journaling clipboard contents. -**Stock Sunshine on macOS does not implement this text-input path**; the action -is disabled for configured Mac hosts. Other hosts still need a live input check. -[Moonlight's implementation](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/keyboard.cpp) -and [the tested Sunshine Mac implementation](https://github.com/LizardByte/Sunshine/blob/v2026.516.143833/src/platform/macos/input.cpp) -explain the distinction. +`cancel` aliases `restore`. `remove NAME` deletes only the saved definition. +`layout NAME` applies a layout with normal local fill. Placement itself does not +change focus; the app's own launcher may activate its window. ## Format and stable references -Saved definitions live in `~/.config/hypertile/scenes/NAME.json`, mode 0600. -This input example can be saved using `scene save work --file scene.json` after -substituting your layout, zone and configured computer names: +Definitions live in `~/.config/hypertile/scenes/NAME.json`, mode 0600. Save this +input using `scene save work --file scene.json`, substituting your layout/zones: ```json { "version": 1, "layout": "my-layout", "sources": { - "right": { "type": "stream", "computer": "macbook", "profile": "desktop" }, + "right": { + "type": "app", + "desktop_id": "remote-desktops-macbook.desktop", + "app_class": "com.moonlight_stream.Moonlight", + "app_title": "MacBook - Moonlight" + }, "left": { "type": "empty" }, "center": { "type": "local", "app_class": "org.example.Editor" } } } ``` +Remote Desktops publishes `X-RemoteDesktops-WindowClass` and +`X-RemoteDesktops-WindowTitle` in each launcher. Scenes reads those optional +metadata fields and preserves their exact match. This distinguishes computers +whose Moonlight windows share a class. Ordinary apps use the same placement +path. Overlapping app matches within a scene are rejected. + On first save, Hypertile adds a persistent `layout_id` and leaf `id` fields to -that layout without changing its geometry. The saved scene includes `layout_id` -and keys `sources` by leaf ID; each source's `zone` field is a readable name hint. -Use `scene show work` to inspect the normalized document and `scene validate ---file scene.json` for a read-only check. Name-based imports are allowed only -when the input omits `layout_id`. - -Renaming or reordering zones preserves their identities. Splitting retains the -original ID on the original half and gives the new half a new ID. A copied -layout receives new identities. Deletion invalidates references; reusing a name -does not revive the old ID. Layout renames resolve through `layout_id`; missing -or duplicated identities produce an actionable error. Manually copied layout -files must receive fresh identities before being used as different scenes. - -Scene sources stay outside layout geometry. Empty reservations are scoped to a -workspace. No monitor-input source is enabled until a hardware profile has been -validated; local and streamed scenes work without Dell/DDC support. - -## Recovery and limits - -Scene intent, its pre-scene baseline, progress, and source restoration journals -live in the existing private stream state file. The single controller serializes -scene and stream operations. Host restoration pending on an offline computer -blocks that computer's replacement profile, while local content can continue. -Session checkpoints include scene definitions and source references, excluding -transient compositor scene state. Restoring an older checkpoint does not undo an -explicit disconnect recorded by the controller. - -App pins are restored only when the same window still has the scene-owned pin; -manually changed pins are preserved. Live compositor addresses are never used -as saved scene identities. Selecting a different layout directly through other -CLI/keybindings leaves the scene needing attention; apply or restore it to -reconcile content. - -Meeting profiles and system-key capture are described in [STREAMS.md](STREAMS.md). -The [validation record](STREAMS-VALIDATION.md) distinguishes automated recovery -checks, live desktop checks, and hardware/call features still unverified. - -The [Performance panel](STREAM-QUALITY.md) adds measured reconnect timing, -completed decoder statistics and readability assessments. Scheduled collection -uses the same stream controller and is cancelled by superseding scene work. +the layout without changing geometry. Saved sources use leaf IDs, with `zone` +as a readable hint. `scene show NAME` shows the normalized document; +`scene validate --file FILE` checks it without applying changes. Name-based +imports are allowed only when the input omits `layout_id`. + +Renaming/reordering zones preserves IDs. Splitting retains the original ID on +one half and gives the other a new ID. Copied layouts get new IDs. Deleted or +ambiguous identities require choosing replacements; reusing a name does not +revive a deleted zone. Keep one fill/cycle zone available for local overflow. +No monitor-input source is enabled without a validated hardware profile. + +## Service and recovery + +The independent `hypertile-scenes` service owns +`~/.local/state/hypertile/scenes/state.json` and +`$XDG_RUNTIME_DIR/hypertile-scenes/control.sock`. It takes its own writer lock, +so it can run alongside Remote Desktops. It does not read legacy computer +configuration or modify host display journals. The loader starts it; CLI scene +commands also start it on demand. Launch/placement intent is persisted before +effects. Work advances at 200 ms while connecting; settled scenes are checked +at 30-second intervals or when the overlay/CLI requests state. There is no +per-frame scripting in this path. + +A restart in the same compositor preserves consumed launches and placements. +A new compositor waits for session recovery's checkpoint before applying scene +references. Scene-managed app windows are excluded from ordinary app recovery +so there is one launch owner. Checkpoints omit app assignments that the user +moved/closed; moved windows use normal session recovery, including the exact +computer launcher where available. Saved scene files retain their defaults. +Missing scene service/invalid legacy references produce recovery warnings. + +Pins are restored only when the same window still has the scene-owned pin on +the same workspace. Identity checks include compositor address, stable ID, and +PID, and happen again immediately before placement. A lost placement reply +requires explicit reapplication instead of risking an automatic second move. + +## Migrating legacy remote scenes + +Existing `type: "stream"` scene files and legacy host recovery journals are +preserved. They are not automatically converted: install each computer's +Remote Desktops launcher, disconnect/restore legacy Hypertile streams, and +replace those sources with `type: "app"` entries. Legacy stream scenes appear +invalid until migrated. A live legacy stream on a workspace blocks new scene +changes there. Runtime installation also requires disconnecting/restoring legacy +sources first, so replacing the old service cannot orphan their recovery. + +The old `hypertile-stream` CLI remains available for legacy connection recovery; +it is no longer auto-started by the layout loader and its daemon does not apply +old scenes. `hypertile-stream scene ...` forwards to the independent service. +Legacy stream profile changes require disconnecting and reconnecting with +`--profile`. See [legacy stream recovery](STREAMS.md) for preserved journals and +restoration tools. The old stream-specific overlay controls are not offered by +the new app catalog; connection controls belong to Remote Desktops. + +## Validation + +On 2026-09-05, a live MacBook check used the installed desktop launcher with the +new scene service and Lua adapter on temporary workspaces. Initial zone +placement passed; a move to another workspace survived a scene-service restart; +explicit reapplication reused the same client PID and connection generation; +restoring the scene left the app running. Cleanup disconnected the test session +and completed host display restoration. The regular session watcher was paused +during the check and resumed afterward. The installed plugin was not replaced. + +Automated tests cover exact/ambiguous matches, interrupted launches and placement +replies, app-only session recovery, cancelled/superseded operations, XDG launcher +precedence, socket/lock isolation, and preservation of manual moves/closes. +The Windows laptop has not been validated live through this integration. diff --git a/hypertile-layouts.lua b/hypertile-layouts.lua index 9a8ea47..2b39be1 100644 --- a/hypertile-layouts.lua +++ b/hypertile-layouts.lua @@ -54,7 +54,7 @@ end -- second watcher or trigger a second restore. Start after workspace rules. if hl.timer then hl.timer(function() - local command = (os.getenv("HOME") or "") .. "/.local/bin/hypertile-stream" + local command = (os.getenv("HOME") or "") .. "/.local/bin/hypertile-scenes" local f = io.open(command, "r") if f then f:close() diff --git a/hypertile-session.lua b/hypertile-session.lua index 08883d2..d58fb61 100644 --- a/hypertile-session.lua +++ b/hypertile-session.lua @@ -57,6 +57,15 @@ function M.snapshot() for id, s in pairs(streams) do if s.address == win.address and s.pid == win.pid and s.stable_id == win.stable_id then source = id end end + local scene_app + for workspace, scene in pairs(scene_content) do + if workspace == selector(win.workspace) then + for _, ref in pairs(scene.app_placements or {}) do + if ref.address == win.address and ref.pid == win.pid and ref.stable_id == win.stable_id + and live and live.state.pins[win.address] == ref.zone then scene_app = true end + end + end + end out.windows[#out.windows + 1] = { address = win.address, stable_id = win.stable_id, pid = win.pid, class = win.class, title = win.title, initial_class = win.initial_class, initial_title = win.initial_title, @@ -65,7 +74,7 @@ function M.snapshot() fullscreen = win.fullscreen, fullscreen_client = win.fullscreen_client, pin = live and live.state.pins[win.address], grouped = win.group ~= nil, pin_exclusive = live and live.state.exclusive_pins and live.state.exclusive_pins[win.address] or nil, - stream = source, + stream = source, scene_app = scene_app, } end end @@ -161,7 +170,8 @@ function M.scene_clear(request) if live.state.scene_empty then live.state.scene_empty[tostring(old.workspace_id)] = nil end for _, p in ipairs(old.pins or {}) do local w = windows[p.address] - if w and w.stable_id == p.stable_id and w.pid == p.pid and live.state.pins[p.address] == p.zone then + if w and w.stable_id == p.stable_id and w.pid == p.pid and w.workspace + and selector(w.workspace) == request.workspace and live.state.pins[p.address] == p.zone then live.state.pins[p.address] = p.before if live.state.exclusive_pins then live.state.exclusive_pins[p.address] = p.exclusive end end @@ -173,10 +183,19 @@ function M.scene_clear(request) end function M.scene_content_apply(request) + local previous = scene_content[request.workspace] + if request.operation and previous and previous.operation == request.operation then return previous end local ws, live for _, w in ipairs(hl.get_workspaces()) do if selector(w) == request.workspace and w.tiled_layout == request.layout then ws = w end end + if not ws and request.allow_missing_workspace and request.operation then + for _, w in ipairs(hl.get_workspaces()) do + assert(selector(w) ~= request.workspace, "scene workspace layout changed") + end + assert(request.workspace:match("^[1-9][0-9]*$"), "invalid scene workspace") + ws = { id = tonumber(request.workspace) } + end assert(ws, "scene workspace or layout changed") live = engine.live[request.layout:match("^lua:(.+)$")] assert(live, "scene requires a Hypertile layout") @@ -195,7 +214,8 @@ function M.scene_content_apply(request) live.state.scene_empty = live.state.scene_empty or {} live.state.scene_empty[tostring(ws.id)] = empty live.state.exclusive_pins = live.state.exclusive_pins or {} - local record = { workspace_id = ws.id, layout = request.layout, pins = json.array(), results = json.array() } + local record = { workspace_id = ws.id, layout = request.layout, operation = request.operation, + app_placements = {}, pins = json.array(), results = json.array() } scene_content[request.workspace] = record for _, source in ipairs(request.sources) do if source.type == "local" and source.app_class then @@ -219,6 +239,49 @@ function M.scene_content_apply(request) return { results = record.results, pins = record.pins } end +-- Called once per scene operation, with a live identity rather than a class +-- dispatcher. Validation and placement happen together on the compositor thread. +function M.scene_app_place(request) + local record = assert(scene_content[request.workspace], "Scene was superseded") + assert(request.operation and record.operation == request.operation, "Scene was superseded") + assert(record.layout == request.layout, "Scene layout changed") + local live = assert(engine.live[request.layout:match("^lua:(.+)$")], "Scene layout is unavailable") + request.zone = live.compiled.zone_ids[request.zone_id] + assert(request.zone and not live.compiled.leaf_opts[request.zone].spacer, "Scene zone is unavailable") + local ws + for _, candidate in ipairs(hl.get_workspaces()) do + if selector(candidate) == request.workspace then + assert(candidate.tiled_layout == request.layout, "Scene workspace layout changed") + ws = candidate + end + end + local previous = record.app_placements[request.zone_id] + if previous then return previous end + local matches = {} + for _, w in ipairs(hl.get_windows()) do + if w.mapped and w.workspace and w.class == request.app_class + and (not request.app_title or w.title == request.app_title) then matches[#matches + 1] = w end + end + assert(#matches == 1, "App window match changed or is ambiguous") + local w = matches[1] + assert(w.address == request.address and w.stable_id == request.stable_id and w.pid == request.pid, + "App window identity changed") + for _, source in pairs(streams) do + assert(source.address ~= w.address or source.stable_id ~= w.stable_id or source.pid ~= w.pid, + "Disconnect the legacy Hypertile stream before assigning this app") + end + local pin = { address = w.address, stable_id = w.stable_id, pid = w.pid, zone = request.zone, + before = live.state.pins[w.address], exclusive = (live.state.exclusive_pins or {})[w.address] } + -- Mark consumed before dispatching. A lost IPC reply or later manual move + -- must not turn the next call into a second placement. + record.app_placements[request.zone_id] = pin + record.pins[#record.pins + 1] = pin + M.place({ address = w.address, layout = request.layout, + saved = { workspace = request.workspace, pin = request.zone, pin_exclusive = true, floating = false } }) + if ws then refresh_workspace(ws) end + return pin +end + function M.scene_restore_pins(request) for _, saved in ipairs(request.windows or {}) do for _, w in ipairs(hl.get_windows()) do @@ -610,7 +673,7 @@ function M.place(request) local saved, address = request.saved, request.address local window = "address:" .. address dispatch(hl.dsp.window.fullscreen_state, { window = window, internal = 0, client = 0, action = "set" }) - dispatch(hl.dsp.window.move, { window = window, workspace = saved.workspace, silent = true }) + dispatch(hl.dsp.window.move, { window = window, workspace = saved.workspace, follow = false }) dispatch(hl.dsp.window.float, { window = window, action = saved.floating and "on" or "off" }) if saved.floating then dispatch(hl.dsp.window.resize, { window = window, x = saved.size.x, y = saved.size.y }) diff --git a/install.sh b/install.sh index d1eca58..4f5892d 100755 --- a/install.sh +++ b/install.sh @@ -68,6 +68,32 @@ for tool in lua jq python3; do done [[ -e "$hypr/hyprland.lua" ]] || { echo "install.sh: $hypr/hyprland.lua not found; is this an Omarchy 4 (Lua config) system?" >&2; exit 1; } +# Retire old in-memory scene code before installing the independent writer. +# Keep active legacy connection recovery running until the owner migrates it. +python3 - "$bin" <<'PY_SERVICES' +import json +import os +from pathlib import Path +import subprocess +import sys +bin_dir = Path(sys.argv[1]) +env = dict(os.environ) +env.pop("HYPERTILE_SRC", None) +legacy = bin_dir / "hypertile-stream" +if legacy.exists(): + status = subprocess.run([str(legacy), "status", "--json"], env=env, capture_output=True, text=True, timeout=5) + if status.returncode == 0: + if any(r.get("desired") or r.get("journal") for r in json.loads(status.stdout).get("computers", [])): + sys.exit("install.sh: disconnect/restore legacy Hypertile streams before installing this update") + subprocess.run([str(legacy), "stop"], env=env, stdout=subprocess.DEVNULL, check=True, timeout=10) +for name in ("hypertile-scenes", "hypertile-session"): + entry = bin_dir / name + if entry.exists(): + status = subprocess.run([str(entry), "status"], env=env, capture_output=True, timeout=5) + if status.returncode == 0: + subprocess.run([str(entry), "stop"], env=env, stdout=subprocess.DEVNULL, check=True, timeout=10) +PY_SERVICES + mkdir -p "$hypr/layouts" "$bin" "$state" # One backup per edited config file, overwritten on each edit. @@ -81,6 +107,7 @@ done install -m 0755 "$src/bin/hypertile-ctl" "$bin/hypertile-ctl" install -m 0755 "$src/bin/hypertile-session" "$bin/hypertile-session" install -m 0755 "$src/bin/hypertile-stream" "$bin/hypertile-stream" +install -m 0755 "$src/bin/hypertile-scenes" "$bin/hypertile-scenes" session_data="${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/session" mkdir -p "$session_data" for f in "$src"/session/*.py; do install -m 0644 "$f" "$session_data/$(basename "$f")"; done diff --git a/plugin/Content.js b/plugin/Content.js index 324bca2..ccbe083 100644 --- a/plugin/Content.js +++ b/plugin/Content.js @@ -3,7 +3,7 @@ // One short phrase for a stream's observed state or a scene's phase. The // empty string means there is nothing to say (no scene, no state). var STATUS = { - ready: "Ready", restored: "Previous arrangement restored", partial: "Some content needs attention", + "waiting-session": "Waiting for session recovery", "waiting-window": "Opening app…", moved: "Moved", closed: "Closed", ready: "Ready", restored: "Previous arrangement restored", partial: "Some content needs attention", stopping: "Finishing previous connections…", layout: "Applying layout…", connecting: "Connecting…", preflight: "Checking host…", preparing: "Preparing display…", "preparing-display": "Preparing display…", "window-ready": "Connected", "startup-window": "Starting…", reconnecting: "Reconnecting…", @@ -92,6 +92,7 @@ function label(source) { if (!source) return "Local windows" if (source.type === "empty") return "Empty" if (source.type === "local") return source.app_class || "Local windows" + if (source.type === "app") return source.app_name || source.desktop_id return source.computer + (source.profile ? " · " + source.profile : "") } @@ -121,6 +122,11 @@ function detail(source) { if (source.type === "local") return source.status === "needs-attention" ? (source.error || "No matching window on this workspace yet") : "One matching window is pinned here" + if (source.type === "app") return source.error || (source.status === "moved" + ? "Moved by you; apply the scene again to place it here" + : source.status === "closed" ? "Closed by you; apply the scene again to open it" + : source.status === "waiting-window" ? "Waiting for the app window" + : "Placed here once; you can move it to any workspace") var bits = [status(source.status) || "Pending"] var requested = source.runtime && source.runtime.requested ? source.runtime.requested : {} if (source.status === "window-ready") bits.push(audioShort(requested.audio)) diff --git a/plugin/ContentPane.qml b/plugin/ContentPane.qml index 9ff71bf..a5f7e2a 100644 --- a/plugin/ContentPane.qml +++ b/plugin/ContentPane.qml @@ -568,9 +568,24 @@ Column { } } } - Muted { - visible: pane.computers.length === 0 - text: "No computers configured. Pair one in Moonlight and add it to ~/.config/hypertile/computers.json." + Column { + width: pane.width + spacing: Style.spacing.xxs + Label { text: "Installed apps"; topPadding: Style.spacing.xs; bottomPadding: Style.spacing.xxs } + Repeater { + model: pane.catalog.apps || [] + ListRow { + required property var modelData + text: modelData.name + trait: "launch or reuse" + current: pane.source !== null && pane.source.type === "app" && pane.source.desktop_id === modelData.desktop_id + onClicked: pane.overlay.assignApp(modelData) + } + } + Muted { + visible: (pane.catalog.apps || []).length === 0 + text: "Apps with a known window identity appear here. Open an installed app to help identify it." + } } Column { diff --git a/plugin/Overlay.qml b/plugin/Overlay.qml index c0cfbed..7761e47 100644 --- a/plugin/Overlay.qml +++ b/plugin/Overlay.qml @@ -270,6 +270,14 @@ Item { runCtl(args, "Putting " + what + " in " + selected + "…", "") } + function assignApp(app) { + if (!selected || !viewedIsActive) { errorText = "Select a zone in the current layout"; return } + var args = ["scene", "content", "--workspace", workspaceId, "--zone", selected, "--type", "app", + "--desktop-id", app.desktop_id, "--app-class", app.app_class, "--json"] + if (app.app_title) args.push("--app-title", app.app_title) + runCtl(args, "Opening " + app.name + " in " + selected + "…", "") + } + // Focus-taking actions close the overlay (it holds the keyboard). function streamAction(action, computer, closeOverlay) { if (closeOverlay) { diff --git a/session/service.py b/session/service.py index a2bacb9..f0f73af 100644 --- a/session/service.py +++ b/session/service.py @@ -194,6 +194,7 @@ def __init__(self, config, proc=Path("/proc")): raise ValueError("replay must be an array of command names") self.proc = proc self.entries = {} + self.exact_entries = [] directories = [Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share")] directories += [Path(p) for p in (os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share").split(":")] seen = set() @@ -210,6 +211,10 @@ def __init__(self, config, proc=Path("/proc")): app = entry["Desktop Entry"] if app.get("Type") != "Application" or app.getboolean("Hidden", fallback=False): continue + exact_class = app.get("X-RemoteDesktops-WindowClass") + exact_title = app.get("X-RemoteDesktops-WindowTitle") + if exact_class and exact_title: + self.exact_entries.append((exact_class, exact_title, str(path))) keys = [desktop_id[:-len(".desktop")], app.get("StartupWMClass", "")] for key in keys: if key: @@ -229,6 +234,11 @@ def recipe(self, window): if not isinstance(argv, list) or not argv or not all(isinstance(a, str) for a in argv): raise ValueError(f"apps.{cls}.argv must be a nonempty array of strings") return {"argv": argv, "per_window": explicit.get("per_window", False)} + exact = [path for app_class, title, path in self.exact_entries + if window.get("class") == app_class and window.get("title") == title] + if exact: + return {"argv": ["gio", "launch", exact[0]], "per_window": False, + "match": {"class": window["class"], "title": window["title"]}} if len(exact) == 1 else None # Browser owns the tabs and profiles. Only carry profile selectors; # startup URLs, remote-debugging flags and arbitrary process args are # deliberately not replayed. @@ -345,9 +355,13 @@ def capture(self, desktop): return desktop +def recipe_matches(saved, candidate): + return all(candidate.get(k) == v for k, v in (saved.get("launch") or {}).get("match", {}).items()) + + def match_windows(saved, current, matches, same_instance=False): """Match uniquely, allowing titles to settle. Never guess between peers.""" - available = {w["address"]: w for w in current if w["address"] not in matches.values()} + available = {w["address"]: w for w in current if not w.get("scene_app") and w["address"] not in matches.values()} pending = [w for w in saved if w["address"] not in matches] if same_instance: for old in pending[:]: @@ -363,7 +377,8 @@ def identity(w): if field and not old.get(field): continue peers = [w for w in pending if identity(w) == identity(old) and (not field or w.get(field) == old.get(field))] - candidates = [w for w in available.values() if identity(w) == identity(old) and (not field or w.get(field) == old.get(field))] + candidates = [w for w in available.values() if identity(w) == identity(old) and recipe_matches(old, w) + and (not field or w.get(field) == old.get(field))] if len(peers) == len(candidates) == 1: live = candidates[0] matches[old["address"]] = live["address"] @@ -402,6 +417,7 @@ def reap(self): def tick(self, now): current = self.compositor.snapshot() + current["windows"] = [w for w in current["windows"] if not w.get("scene_app")] alive = {w["address"] for w in current["windows"]} for old, new in list(self.matches.items()): if new not in alive: @@ -409,8 +425,9 @@ def tick(self, now): self.placed.discard(old) if self.outstanding: old, cls, before, until = self.outstanding + saved = next(w for w in self.desktop["windows"] if w["address"] == old) candidates = [w for w in current["windows"] if w["address"] not in before - and (w["initial_class"] or w["class"]) == cls] + and (w["initial_class"] or w["class"]) == cls and recipe_matches(saved, w)] if len(candidates) == 1 and candidates[0]["address"] not in self.matches.values(): self.matches[old] = candidates[0]["address"] self.outstanding = None @@ -454,8 +471,9 @@ def tick(self, now): if not recipe: self.hopeless[saved["address"]] = "no launch recipe" continue - peers = [w for w in current["windows"] if (w["initial_class"] or w["class"]) == cls] - expected = sum((w["initial_class"] or w["class"]) == cls for w in self.desktop["windows"]) + matcher = {"launch": recipe} + peers = [w for w in current["windows"] if (w["initial_class"] or w["class"]) == cls and recipe_matches(matcher, w)] + expected = sum((w["initial_class"] or w["class"]) == cls and recipe_matches(matcher, w) for w in self.desktop["windows"]) if len(peers) >= expected or (peers and not recipe.get("per_window")): continue key = saved["address"] if recipe.get("per_window") else str(saved["pid"]) + json.dumps(recipe["argv"]) diff --git a/session/streams.py b/session/streams.py index a72fcbb..52c2b91 100644 --- a/session/streams.py +++ b/session/streams.py @@ -1,4 +1,5 @@ -"""Session integration: remote sources remain owned by the stream controller.""" +"""Session integration for independent scenes and preserved legacy stream recovery.""" +import copy import json import os from pathlib import Path @@ -6,14 +7,19 @@ def capture(desktop): - path = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "hypertile/streams/state.json" + root = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "hypertile" + path = root / "streams/state.json" try: state = json.loads(path.read_text()) except FileNotFoundError: - return desktop + state = {"version": 1, "computers": {}} if state.get("version") != 1: raise ValueError("unsupported stream state version; session capture paused") - if state.get("browse", {}).get("active"): + scene_path = root / "scenes/state.json" + scene_state = json.loads(scene_path.read_text()) if scene_path.exists() else state + if scene_state.get("version") != 1: + raise ValueError("unsupported scene state version; session capture paused") + if scene_state.get("browse", {}).get("active"): raise ValueError("layout preview is active; retaining the last committed session checkpoint") sources, tokens = [], set() for r in state["computers"].values(): @@ -21,9 +27,32 @@ def capture(desktop): tokens.add(("HYPERTILE_STREAM_TOKEN=" + r["token"]).encode()) if r["desired"]: sources.append({"computer": r["computer"], "profile": r["profile"], **r["assignment"]}) + scene_refs, scene_windows = [], set() + for workspace, record in scene_state.get("scenes", {}).items(): + if not record.get("document") or record.get("phase") in ("restored", "waiting-session"): + continue + doc = copy.deepcopy(record["document"]) + for key, source in list(doc["sources"].items()): + if source["type"] != "app": + continue + app = record.get("apps", {}).get(key, {}) + ref = app.get("window") + window = next((w for w in desktop["windows"] if ref and all(w.get(k) == v for k, v in ref.items())), None) + if app.get("status") in ("moved", "closed") or (ref and (not window or window["workspace"] != workspace or window.get("pin") != source["zone"] or window.get("floating"))): + del doc["sources"][key] # Recovery preserves user departures; saved definitions stay intact. + elif window: + scene_windows.add(window["address"]) + else: + candidates = [w for w in desktop["windows"] if w.get("class") == source["app_class"] + and (not source.get("app_title") or w.get("title") == source["app_title"])] + if len(candidates) > 1: + del doc["sources"][key] # Preserve every unassigned peer through normal app recovery. + elif candidates: + scene_windows.add(candidates[0]["address"]) + scene_refs.append({"workspace": workspace, "document": doc}) windows = [] for w in desktop["windows"]: - managed = bool(w.get("stream")) + managed = bool(w.get("stream")) or w["address"] in scene_windows if tokens and w.get("pid"): try: env = (Path("/proc") / str(w["pid"]) / "environ").read_bytes().split(b"\0") @@ -35,9 +64,7 @@ def capture(desktop): desktop["windows"] = windows desktop["streams"] = sorted(sources, key=lambda s: s["computer"]) desktop.pop("scene_content", None) # Compositor addresses are not scene definitions. - desktop["scenes"] = [{"workspace": workspace, "document": r["document"]} - for workspace, r in state.get("scenes", {}).items() - if r.get("document") and r.get("phase") != "restored"] + desktop["scenes"] = scene_refs addresses = {w["address"] for w in windows} for ws in desktop["workspaces"]: ws["order"] = [a for a in ws.get("order", []) if a in addresses] @@ -47,16 +74,33 @@ def capture(desktop): def restore(sources, scenes=()): - if not sources and not scenes: - return [] - runtime = Path(os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}") / "hypertile-stream/control.sock" - try: - with socket.socket(socket.AF_UNIX) as client: - client.settimeout(1) - client.connect(str(runtime)) - client.sendall(json.dumps({"command": "session-restore", "sources": sources, "scenes": scenes}).encode() + b"\n") - # Submission is optional for local recovery. Stream state owns retries, - # offline assignments and disconnect tombstones independently. - return [] - except OSError: - return ["Stream controller unavailable; local recovery continued. Remote assignments remain in the snapshot."] + warnings = [] + runtime = Path(os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}") + # Keep source recovery available without coupling generic scenes to it. + legacy = [r for r in scenes if any(s["type"] == "stream" for s in r["document"]["sources"].values())] + if legacy: + warnings.append("Legacy stream scenes need migration to installed app sources; their saved definitions were kept.") + scenes = [r for r in scenes if r not in legacy] + for entry, payload, label in ( + ("hypertile-stream", {"command": "session-restore", "sources": sources}, "Stream controller"), + ("hypertile-scenes", {"command": "session-restore", "scenes": scenes}, "Scene service"), + ): + if not payload.get("sources") and not payload.get("scenes"): + continue + try: + with socket.socket(socket.AF_UNIX) as client: + client.settimeout(1) + client.connect(str(runtime / entry / "control.sock")) + client.sendall(json.dumps(payload).encode() + b"\n") + data = bytearray() + while not data.endswith(b"\n") and len(data) < 65536: + part = client.recv(4096) + if not part: + break + data.extend(part) + result = json.loads(data) + if not result.get("ok"): + raise ValueError(result.get("error", "restore was refused")) + except (OSError, ValueError) as error: + warnings.append(label + " unavailable; local recovery continued. Saved assignments were kept. " + str(error)) + return warnings diff --git a/stream/apps.py b/stream/apps.py new file mode 100644 index 0000000..e4795cc --- /dev/null +++ b/stream/apps.py @@ -0,0 +1,186 @@ +"""Installed desktop entries and one-shot scene placement. Apps own their lifecycle.""" +import configparser +import os +from pathlib import Path +import subprocess +import time + + +def text(value, label): + if not isinstance(value, str) or not value or len(value) > 250 or any(ord(c) < 32 for c in value): + raise ValueError("invalid " + label) + return value + + +def matches(window, source): + return window.get("class") == source["app_class"] and (not source.get("app_title") or window.get("title") == source["app_title"]) + + +def identity(window): + return {key: window[key] for key in ("address", "stable_id", "pid")} + + +class DesktopApps: + def __init__(self): + self.entries, self.next_scan = {}, 0 + self.children = {} + + def scan(self): + if time.monotonic() < self.next_scan: + return self.entries + directories = [Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share")] + directories += [Path(p) for p in (os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share").split(":") if p] + entries, seen = {}, set() + for directory in directories: + base = directory / "applications" + for path in sorted(base.rglob("*.desktop")): + desktop_id = str(path.relative_to(base)).replace("/", "-") + if desktop_id in seen: + continue + seen.add(desktop_id) # Hidden user entries mask system entries too. + parser = configparser.ConfigParser(interpolation=None, strict=False) + try: + parser.read(path) + app = parser["Desktop Entry"] + if app.get("Type") != "Application" or app.getboolean("Hidden", fallback=False) or not app.get("Exec"): + continue + match = app.get("X-RemoteDesktops-WindowClass") or app.get("StartupWMClass") + title = app.get("X-RemoteDesktops-WindowTitle") + entry = {"desktop_id": desktop_id, "name": app.get("Name", desktop_id), "path": str(path), + "visible": not app.getboolean("NoDisplay", fallback=False)} + if match: + entry["app_class"] = text(match, "app class") + if title: + entry["app_title"] = text(title, "app title") + entries[desktop_id] = entry + except (OSError, UnicodeError, configparser.Error, KeyError, ValueError): + continue + self.entries, self.next_scan = entries, time.monotonic() + 5 + return entries + + def resolve(self, source): + desktop_id = text(source.get("desktop_id"), "desktop ID") + if not desktop_id.endswith(".desktop") or "/" in desktop_id or "\\" in desktop_id: + raise ValueError("Use an installed desktop ID, not a path") + entry = self.scan().get(desktop_id) + if not entry: + raise ValueError("Install the app first: " + desktop_id) + result = {"desktop_id": desktop_id, "app_name": entry["name"], + "app_class": text(source.get("app_class") or entry.get("app_class"), "app class")} + title = source.get("app_title") or entry.get("app_title") + if title: + result["app_title"] = text(title, "app title") + # Per-computer launchers publish exact identity; do not weaken it to + # Moonlight's shared class and accidentally claim another computer. + for key in ("app_class", "app_title"): + if entry.get(key) and result.get(key) != entry[key]: + raise ValueError("Window match disagrees with installed app: " + desktop_id) + return result + + def catalog(self, windows): + out = [] + for entry in self.scan().values(): + if not entry["visible"]: + continue + app = {k: v for k, v in entry.items() if k not in ("path", "visible")} + # Wayland desktop IDs often are the app ID. Only offer this fallback + # when a live window demonstrates it; otherwise the CLI accepts an + # explicit match instead of guessing. + if not app.get("app_class"): + stem = entry["desktop_id"][:-8] + if any(w.get("class") == stem for w in windows): + app["app_class"] = stem + if app.get("app_class"): + out.append(app) + return sorted(out, key=lambda v: (v["name"].casefold(), v["desktop_id"])) + + def launch(self, source): + # Re-resolve immediately before launch. gio implements desktop Exec + # expansion; neither stored commands nor shell interpolation are used. + self.next_scan = 0 + self.resolve(source) + path = self.entries[source["desktop_id"]]["path"] + self.children[source["desktop_id"]] = subprocess.Popen(["gio", "launch", path], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + + def failure(self, desktop_id): + child = self.children.get(desktop_id) + code = child.poll() if child else None + if code is not None: + del self.children[desktop_id] + return "App launcher exited with status " + str(code) if code else None + + +class AppPlacement: + def __init__(self, controller, desktop=None): + self.ctl, self.desktop = controller, desktop or DesktopApps() + self.launches = controller.state.setdefault("app_launches", {}) + + def reap(self): + for desktop_id in list(self.desktop.children): + error = self.desktop.failure(desktop_id) + if error and desktop_id in self.launches: + self.launches[desktop_id]["error"] = error + + def retry(self, record): + for source in record["document"]["sources"].values(): + if source["type"] == "app": + attempt = self.launches.get(source["desktop_id"]) + if attempt and (attempt.get("error") or self.ctl.now() >= attempt["deadline"]): + self.launches.pop(source["desktop_id"], None) + + def observe(self, record, snap): + before = [(k, v["status"]) for k, v in record.get("apps", {}).items()] + for key, state in record.get("apps", {}).items(): + if state["status"] != "ready": + continue + source = record["document"]["sources"][key] + window = next((w for w in snap["windows"] if identity(w) == state["window"]), None) + if not window: + state["status"] = "closed" + elif window["workspace"] != record["workspace"] or window.get("floating") or window.get("pin") != source["zone"]: + state["status"] = "moved" + return before != [(k, v["status"]) for k, v in record.get("apps", {}).items()] + + def step(self, record, snap): + self.observe(record, snap) + states = record.setdefault("apps", {}) + results = [] + for key, source in record["document"]["sources"].items(): + if source["type"] != "app": + continue + state = states.setdefault(key, {"status": "pending"}) + if state["status"] in ("pending", "waiting-window"): + found = [w for w in snap["windows"] if matches(w, source) and not w.get("stream")] + if len(found) > 1: + state.update(status="needs-attention", error="More than one matching window is open; close extras or use an exact title") + elif found: + window = found[0] + # Consume placement before IPC. An uncertain reply must never + # cause a later move to be undone by a retry after restart. + state.update(status="needs-attention", error="Placement was interrupted; apply the scene again", window=identity(window)) + self.ctl.persist() + pin = self.ctl.compositor.call("scene_app_place", {**identity(window), "workspace": record["workspace"], + "layout": "lua:" + record["document"]["layout"], "zone": source["zone"], "zone_id": key, + "operation": record["operation"], "app_class": source["app_class"], "app_title": source.get("app_title")}) + record.setdefault("pins", []).append(pin) + state.update(status="ready", error=None) + self.launches.pop(source["desktop_id"], None) + else: + attempt = self.launches.get(source["desktop_id"]) + if not attempt: + attempt = {"deadline": self.ctl.now() + 45} + self.launches[source["desktop_id"]] = attempt + self.ctl.persist() # A crash here leaves an uncertain launch, never an automatic duplicate. + try: + self.desktop.launch(source) + except (OSError, ValueError) as error: + attempt["error"] = str(error) + failure = self.desktop.failure(source["desktop_id"]) + if failure: + attempt["error"] = failure + if self.ctl.now() >= attempt["deadline"]: + attempt.setdefault("error", "No matching window appeared within 45 seconds; check the app, then Retry") + state.update(status="needs-attention" if attempt.get("error") else "waiting-window", error=attempt.get("error")) + results.append({"zone": source["zone"], "status": state["status"], "error": state.get("error")}) + return results diff --git a/stream/controller.py b/stream/controller.py index eed7272..017213b 100644 --- a/stream/controller.py +++ b/stream/controller.py @@ -12,7 +12,6 @@ import os from pathlib import Path import re -import select import shlex import shutil import signal @@ -29,6 +28,7 @@ from audio import host_headset from quality import Tracker, VideoStats from browse import Browser +from ipc import request, daemon import windows_display CLASS = "com.moonlight_stream.Moonlight" @@ -464,7 +464,7 @@ def stop(self, record, force=False): class Controller: - def __init__(self, root, config, compositor, processes=None, host_factory=Host, now=time.time): + def __init__(self, root, config, compositor, processes=None, host_factory=Host, now=time.time, manage_scenes=True): self.root, self.config, self.compositor = root, config, compositor self.processes = processes or Processes(root, compositor) self.host_factory, self.now = host_factory, now @@ -475,6 +475,7 @@ def __init__(self, root, config, compositor, processes=None, host_factory=Host, self.applied = {} self.inhibitors = {} self.scenes = Manager(self, lambda: configuration(self.config)) + self.manage_scenes = manage_scenes self.quality = Tracker(self) self.browser = Browser(self) for record in self.records.values(): @@ -509,10 +510,12 @@ def public(self, r): return out def command(self, request): - self.browser.before_command(request) + if self.manage_scenes: + self.browser.before_command(request) if request.get("command") not in ("status", "stop"): self.finish_swap() - self.scenes.interrupted(request) + if self.manage_scenes: + self.scenes.interrupted(request) if request.get("command") in ("connect", "disconnect", "restore", "release", "retry", "reconnect"): computer = request.get("computer") require(isinstance(computer, str) and NAME.fullmatch(computer), "invalid computer ID") @@ -524,6 +527,7 @@ def command(self, request): def _command(self, request): action, computer = request["command"], request.get("computer") if action == "scene": + require(self.manage_scenes, "Use hypertile-ctl scene; Scenes has its own service") return self.scenes.command(request) if action == "status": if computer: @@ -579,7 +583,8 @@ def _command(self, request): self.records[source["computer"]] = r self.persist() outcomes.append(self.public(r)) - self.scenes.restore_refs(request.get("scenes", [])) + if self.manage_scenes: + self.scenes.restore_refs(request.get("scenes", [])) return {"sources": outcomes} r = self.records.get(computer) if action in ("quality", "measure", "readability"): @@ -602,6 +607,7 @@ def _command(self, request): self.compositor.call("stream_shortcut", {"computer": computer, "action": action}) return {"sent": action, "computer": computer} if action == "profile": + require(self.manage_scenes, "Disconnect, then connect with --profile to change a legacy stream profile") require(r and r["desired"], "connect this computer before changing its profile") return self.scenes.command({"action": "content", "workspace": r["assignment"]["workspace"], "zone": r["assignment"]["zone"], "type": "stream", "computer": computer, @@ -711,7 +717,8 @@ def finish_swap(self): else: r["assignment"].pop("zone_id", None) self.applied.pop(w["computer"], None) - self.scenes.swapped() + if self.manage_scenes: + self.scenes.swapped() self.state.pop("swap") self.persist() @@ -942,15 +949,17 @@ def step(self, r, snap): self.assign(r) def tick(self): - self.browser.tick() + if self.manage_scenes: + self.browser.tick() self.finish_swap() before = json.dumps(self.state, sort_keys=True) self.quality.harvest() self.quality.due() - self.scenes.tick() + if self.manage_scenes: + self.scenes.tick() snap = self.compositor.snapshot() for r in self.records.values(): - if r["assignment"]["workspace"] in self.browser.active or self.scenes.blocks(r["computer"]): + if self.manage_scenes and (r["assignment"]["workspace"] in self.browser.active or self.scenes.blocks(r["computer"])): continue phase, started = r["phase"], self.quality.clock() try: @@ -994,91 +1003,11 @@ def tick_interval(self): for r in self.records.values()) else 1 -def request(runtime, payload, timeout=55): - with socket.socket(socket.AF_UNIX) as client: - client.settimeout(timeout) - client.connect(str(runtime / "control.sock")) - client.sendall(json.dumps(payload).encode() + b"\n") - data = bytearray() - while not data.endswith(b"\n") and len(data) < 2_000_000: - part = client.recv(65536) - if not part: - break - data.extend(part) - result = json.loads(data) - require(result.get("ok"), result.get("error", "controller request failed")) - return result["result"] - - -def daemon(root, runtime, config): - root.mkdir(mode=0o700, parents=True, exist_ok=True) - runtime.mkdir(mode=0o700, parents=True, exist_ok=True) - os.chmod(root, 0o700) - os.chmod(runtime, 0o700) - with (root / "writer.lock").open("w") as lock: - instance = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE") - require(instance, "start the controller inside the Hyprland session") - try: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - previous = request(runtime, {"command": "status"}) - if previous.get("instance") == instance: - return - alive = subprocess.run(["hyprctl", "-i", previous["instance"], "version"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5) - require(alive.returncode != 0, "stream controller belongs to another running compositor") - request(runtime, {"command": "stop"}) - for _ in range(50): - try: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - break - except BlockingIOError: - time.sleep(.1) - else: - raise ValueError("previous stream controller has not stopped") - controller = Controller(root, config, Compositor(instance, runtime)) - def stop(*_): - controller.running = False - signal.signal(signal.SIGTERM, stop) - signal.signal(signal.SIGINT, stop) - path = runtime / "control.sock" - path.unlink(missing_ok=True) - with socket.socket(socket.AF_UNIX) as server: - server.bind(str(path)) - os.chmod(path, 0o600) - server.listen(16) - try: - next_tick = 0 - while controller.running: - if select.select([server], [], [], min(.2, max(0, next_tick - time.monotonic())))[0]: - with server.accept()[0] as client: - client.settimeout(2) - try: - data = bytearray() - while not data.endswith(b"\n") and len(data) < 65536: - part = client.recv(8192) - if not part: - break - data.extend(part) - result = {"ok": True, "result": controller.command(json.loads(data))} - except Exception as error: - result = {"ok": False, "error": str(error)} - try: - client.sendall(json.dumps(result).encode() + b"\n") - except OSError: - pass # Intent remains durable if the CLI disconnects. - if time.monotonic() >= next_tick: - try: - controller.tick() - except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired): - # A compositor outage must not erase sources or launch duplicates. - controller.applied.clear() - next_tick = time.monotonic() + controller.tick_interval() - finally: - path.unlink(missing_ok=True) - def main(): + if len(sys.argv) > 1 and sys.argv[1] == "scene": + from scene_service import main as scene_main + return scene_main(sys.argv[2:]) os.umask(0o077) root, runtime, config = paths() parser = argparse.ArgumentParser(description="Manage paired remote desktops in Hypertile zones") @@ -1142,7 +1071,7 @@ def main(): launch_job(args.path) return if args.command == "daemon": - daemon(root, runtime, config) + daemon(root, runtime, config, lambda r, c, h: Controller(r, c, h, manage_scenes=False)) return if args.command == "computers": computers = configuration(config) diff --git a/stream/ipc.py b/stream/ipc.py new file mode 100644 index 0000000..4332d9f --- /dev/null +++ b/stream/ipc.py @@ -0,0 +1,101 @@ +"""Private single-writer IPC shared by the independent scene and legacy stream services.""" +import fcntl +import json +import os +import select +import signal +import socket +import subprocess +import time +from service import Compositor + +def require(value, message): + if not value: + raise ValueError(message) + + +def request(runtime, payload, timeout=55): + with socket.socket(socket.AF_UNIX) as client: + client.settimeout(timeout) + client.connect(str(runtime / "control.sock")) + client.sendall(json.dumps(payload).encode() + b"\n") + data = bytearray() + while not data.endswith(b"\n") and len(data) < 2_000_000: + part = client.recv(65536) + if not part: + break + data.extend(part) + result = json.loads(data) + require(result.get("ok"), result.get("error", "controller request failed")) + return result["result"] + + +def daemon(root, runtime, config, factory): + root.mkdir(mode=0o700, parents=True, exist_ok=True) + runtime.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(root, 0o700) + os.chmod(runtime, 0o700) + with (root / "writer.lock").open("w") as lock: + instance = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE") + require(instance, "start the controller inside the Hyprland session") + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + previous = request(runtime, {"command": "status"}) + if previous.get("instance") == instance: + return + alive = subprocess.run(["hyprctl", "-i", previous["instance"], "version"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5) + require(alive.returncode != 0, "controller belongs to another running compositor") + request(runtime, {"command": "stop"}) + for _ in range(50): + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + time.sleep(.1) + else: + raise ValueError("previous controller has not stopped") + controller = factory(root, config, Compositor(instance, runtime)) + def stop(*_): + controller.running = False + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + path = runtime / "control.sock" + path.unlink(missing_ok=True) + with socket.socket(socket.AF_UNIX) as server: + server.bind(str(path)) + os.chmod(path, 0o600) + server.listen(16) + try: + next_tick = 0 + while controller.running: + if select.select([server], [], [], min(1, max(0, next_tick - time.monotonic())))[0]: + with server.accept()[0] as client: + client.settimeout(2) + try: + data = bytearray() + while not data.endswith(b"\n") and len(data) < 65536: + part = client.recv(8192) + if not part: + break + data.extend(part) + payload = json.loads(data) + result = {"ok": True, "result": controller.command(payload)} + if payload.get("command") not in ("status", "stop"): + next_tick = 0 + except Exception as error: + result = {"ok": False, "error": str(error)} + try: + client.sendall(json.dumps(result).encode() + b"\n") + except OSError: + pass # Intent remains durable if the CLI disconnects. + if time.monotonic() >= next_tick: + try: + controller.tick() + except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired): + # A compositor outage must not erase sources or launch duplicates. + controller.applied.clear() + next_tick = time.monotonic() + controller.tick_interval() + finally: + path.unlink(missing_ok=True) diff --git a/stream/scene_service.py b/stream/scene_service.py new file mode 100644 index 0000000..903495b --- /dev/null +++ b/stream/scene_service.py @@ -0,0 +1,136 @@ +"""Independent scene writer: no host config, stream lock, or connection controller.""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +from service import atomic_json, read_json +from scenes import Manager +from browse import Browser +from ipc import daemon, request + + +class SceneController: + def __init__(self, root, config, compositor, now=time.time): + self.root, self.config, self.compositor, self.now = root, config, compositor, now + self.state = read_json(root / "state.json") if (root / "state.json").exists() else {"version": 1} + if self.state.get("version") != 1: + raise ValueError("unsupported scene state version") + self.records, self.applied, self.running = {}, {}, True + self.generic_scenes = True + self.scenes = Manager(self, lambda: {}) + self.browser = Browser(self) + if self.state.get("instance") != compositor.instance: + self.state["app_launches"].clear() + for record in self.scenes.records.values(): + if record.get("phase") != "restored": + record.update(phase="waiting-session") + record.pop("baseline", None) + self.state["instance"] = compositor.instance + self.persist() + + def persist(self): + atomic_json(self.root / "state.json", self.state) + + def command(self, payload): + command = payload.get("command") + if command == "status": + return {"instance": self.compositor.instance, "scenes": len(self.scenes.records)} + if command == "stop": + for workspace in list(self.browser.active): + self.browser.end(workspace) + self.running = False + return {"stopping": True} + if command == "session-restore": + self.scenes.restore_refs(payload.get("scenes", [])) + return {"accepted": True} + if command != "scene": + raise ValueError("Scenes only manages layouts and app placement") + if payload.get("action") in ("apply", "content", "layout", "restore", "cancel", "retry", "browse"): + snap = self.compositor.snapshot() + workspace = str(payload.get("workspace") or snap["workspace"]) + if any(s.get("workspace") == workspace for s in snap.get("streams", [])): + raise ValueError("Disconnect legacy Hypertile streams on this workspace before changing its scene") + self.browser.before_command(payload) + return self.scenes.command(payload) + + def tick(self): + before = json.dumps(self.state, sort_keys=True) + self.scenes.apps.reap() + self.browser.tick() + self.scenes.tick() + if before != json.dumps(self.state, sort_keys=True): + self.persist() + + def tick_interval(self): + if any(r["phase"] in ("stopping", "layout", "connecting", "waiting-workspace", "restore-builtin") + or any(a["status"] in ("pending", "waiting-window") for a in r.get("apps", {}).values()) + for r in self.scenes.records.values()): + return .2 + return 1 if self.browser.active else 30 + + +def paths(): + return (Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "hypertile/scenes", + Path(os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}") / "hypertile-scenes", + Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "hypertile/scenes.json") + + +def main(argv=None): + os.umask(0o077) + parser = argparse.ArgumentParser(description="Save and apply layouts with ordinary desktop apps") + commands = parser.add_subparsers(dest="action", required=True) + for action in ("daemon", "status", "stop", "list", "show", "save", "validate", "apply", "current", "restore", "cancel", "retry", "remove", "catalog", "content", "layout", "browse", "browse-end"): + child = commands.add_parser(action) + if action in ("show", "save", "apply", "remove", "layout", "browse"): + child.add_argument("name") + if action in ("browse", "browse-end", "catalog"): + child.add_argument("--browse-token", required=action != "catalog") + if action in ("validate", "save"): + child.add_argument("--file") + if action == "content": + child.add_argument("--zone", required=True) + child.add_argument("--type", choices=("local", "app", "empty"), required=True) + child.add_argument("--desktop-id") + child.add_argument("--app-class") + child.add_argument("--app-title") + child.add_argument("--workspace") + child.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + root, runtime, config = paths() + try: + if args.action == "daemon": + daemon(root, runtime, config, SceneController) + return 0 + payload = {**vars(args), "command": args.action if args.action in ("stop", "status") else "scene"} + if getattr(args, "file", None): + payload["document"] = json.loads(sys.stdin.read() if args.file == "-" else Path(args.file).read_text()) + if args.action == "validate" and not payload.get("document"): + raise ValueError("validate requires --file FILE (or - for stdin)") + if args.action not in ("stop", "status"): + try: + request(runtime, {"command": "status"}, timeout=1) + except (OSError, ValueError): + entry = Path(__file__).resolve().parents[1] / "bin/hypertile-scenes" + if not entry.exists(): + entry = Path.home() / ".local/bin/hypertile-scenes" + subprocess.Popen([sys.executable, str(entry), "daemon"], stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + for _ in range(50): + try: + request(runtime, {"command": "status"}, timeout=.2) + break + except (OSError, ValueError): + time.sleep(.1) + print(json.dumps(request(runtime, payload), indent=2)) + return 0 + except (OSError, ValueError, KeyError, RuntimeError, subprocess.TimeoutExpired) as error: + print("hypertile-scenes: " + str(error), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stream/scenes.py b/stream/scenes.py index 0c69c2d..07c1b56 100644 --- a/stream/scenes.py +++ b/stream/scenes.py @@ -1,8 +1,4 @@ -"""Scene definitions and orchestration, hosted by the stream single writer. - -No network/process owner here: every source operation delegates to Controller. -Scene intent, its baseline, and its progress live in the controller's journal. -""" +"""Scene definitions and orchestration. App lifecycle belongs to each app.""" import copy import json import os @@ -13,6 +9,7 @@ import uuid from service import atomic_json +from apps import AppPlacement def check(condition, message): @@ -96,6 +93,7 @@ def persist(self, workspace, rule): class Manager: def __init__(self, controller, computers, layouts=None, directory=None): self.ctl, self.computers = controller, computers + self.apps = AppPlacement(controller) self.layouts = layouts or Layouts() self.directory = directory or controller.config.parent / "scenes" self.records = controller.state.setdefault("scenes", {}) @@ -132,10 +130,17 @@ def resolve(self, doc, migrate=False): check(len(matches) == 1, "Scene zone is missing: " + str(source.get("zone", key)) + "; choose its replacement") leaf = matches[0] kind = source.get("type") - check(kind in ("local", "stream", "empty"), "monitor inputs require a validated hardware profile") + check(kind in ("local", "stream", "empty", "app"), "monitor inputs require a validated hardware profile") check(not leaf.get("spacer") or kind == "empty", "A spacer can only contain Empty") value = {"type": kind, "zone": leaf["name"]} + if kind == "app": + value.update(self.apps.desktop.resolve(source)) + match = (value["app_class"], value.get("app_title")) + check(not any(c == match[0] and (not t or not match[1] or t == match[1]) for c, t in apps), + "Overlapping app matches cannot occupy separate scene zones") + apps.add(match) if kind == "stream": + check(not getattr(self.ctl, "generic_scenes", False), "Legacy stream source: replace it with an installed app desktop ID") computer, profile = source.get("computer"), source.get("profile") check(computer in computers, "Configure computer " + str(computer) + " first") check(profile in computers[computer]["profiles"], "Unknown profile for " + computer) @@ -146,8 +151,8 @@ def resolve(self, doc, migrate=False): app = source["app_class"] check(isinstance(app, str) and 0 < len(app) <= 250 and "\n" not in app, "invalid app class") check(app != "com.moonlight_stream.Moonlight", "Choose a configured computer for Moonlight") - check(app not in apps, "An app class can occupy only one scene zone") - apps.add(app) + check(not any(c == app for c, _ in apps), "An app class can occupy only one scene zone") + apps.add((app, None)) value["app_class"] = app if kind in ("empty", "stream"): blocked.add(leaf["name"]) @@ -220,10 +225,16 @@ def public(self, record): for result in record.get("results", []): if result["zone"] == source["zone"]: item.update(result) + if source["type"] == "app": + state = record.get("apps", {}).get(key, {}) + item.update(status=state.get("status", "pending"), error=state.get("error")) out["sources"].append(item) return out - def start(self, doc, workspace, snap, restoring=False): + def has_apps(self, document): + return any(s["type"] == "app" for s in document["sources"].values()) + + def start(self, doc, workspace, snap, restoring=False, force=False): document, spec = self.resolve(doc) check(document.get("layout_id"), "Save the scene first to establish layout and zone identities") for source in document["sources"].values(): @@ -232,12 +243,17 @@ def start(self, doc, workspace, snap, restoring=False): check(not r or not r["desired"] or r["assignment"]["workspace"] == workspace, "Computer is already assigned on another workspace: " + source["computer"]) old = self.records.get(workspace) - if not restoring and old and old.get("document") == document and old["phase"] not in ("needs-attention", "restored", "waiting-workspace") and not old.get("suppressed"): + if old: + self.apps.observe(old, snap) + if not force and not restoring and old and not any(a.get("status") in ("moved", "closed", "needs-attention") for a in old.get("apps", {}).values()) and old.get("document") == document and old["phase"] not in ("needs-attention", "restored", "waiting-workspace", "waiting-session") and not old.get("suppressed"): return self.public(old) if old and old.get("baseline") and old["phase"] != "restored": baseline = copy.deepcopy(old["baseline"]) else: - ws = next(w for w in snap["workspaces"] if w["selector"] == workspace) + ws = next((w for w in snap["workspaces"] if w["selector"] == workspace), None) + if not ws: + check(getattr(self.ctl, "generic_scenes", False) and self.has_apps(document), "Workspace is unavailable") + ws = {"layout": "dwindle"} baseline = {"layout": ws["layout"], "document": self.capture(workspace, snap) if ws["layout"].startswith("lua:") else None, "windows": [{k: w[k] for k in ("address", "stable_id", "pid", "pin", "pin_exclusive") if k in w} for w in snap["windows"] if w["workspace"] == workspace], "instance": self.ctl.compositor.instance} @@ -245,6 +261,16 @@ def start(self, doc, workspace, snap, restoring=False): "generation": (old or {}).get("generation", 0) + 1, "operation": uuid.uuid4().hex, "phase": "stopping", "launched": [], "suppressed": [], "restoring": restoring, "modified": self.modified(document)} record["retired_pins"] = copy.deepcopy((old or {}).get("retired_pins", []) + (old or {}).get("pins", [])) + # A new explicit assignment supersedes pending placement elsewhere. + # An older workspace must not claim the app when its late window arrives. + for other_ws, other in self.records.items(): + if other_ws == workspace or other.get("phase") == "restored": + continue + for key, source in other.get("document", {}).get("sources", {}).items(): + if source["type"] == "app" and any(s["type"] == "app" and s["app_class"] == source["app_class"] + and (not s.get("app_title") or not source.get("app_title") or s["app_title"] == source["app_title"]) + for s in document["sources"].values()): + other.setdefault("apps", {})[key] = {"status": "moved"} self.records[workspace] = record self.ctl.persist() self.stop_superseded(record) @@ -318,7 +344,11 @@ def command(self, request): doc, _ = self.resolve(request["document"]) return {"valid": True, "document": doc} snap = self.ctl.compositor.snapshot() - workspace = self.workspace(request, snap) + requested_workspace = str(request.get("workspace") or snap["workspace"]) + workspace = requested_workspace if action == "current" and requested_workspace in self.records else self.workspace(request, snap) + changed = [self.apps.observe(r, snap) for r in self.records.values()] + if any(changed): + self.ctl.persist() if action == "current": return self.public(self.records.get(workspace)) if action == "catalog": @@ -332,6 +362,7 @@ def command(self, request): "meeting": "unverified"} for p, s in v["profiles"].items()]} for k, v in computers.items()], "streams": [self.ctl.public(r) for r in self.ctl.records.values()], "active_workspaces": [w for w, r in self.records.items() if r["phase"] != "restored"], + "apps": self.apps.desktop.catalog(snap["windows"]), "monitor_inputs": [], "workspace": workspace} if action == "save": doc = request.get("document") or self.capture(workspace, snap) @@ -370,6 +401,9 @@ def command(self, request): if action == "retry": active = self.records.get(workspace) check(active, "No active scene") + if any(s["type"] == "app" for s in active["document"]["sources"].values()): + self.apps.retry(active) + return self.start(active["document"], workspace, snap, force=True) if active["phase"] == "needs-attention": active.update(phase="stopping", error=None) self.stop_superseded(active) @@ -390,9 +424,11 @@ def command(self, request): leaf = next((n for n in leaves(spec) if n["name"] == request.get("zone")), None) check(leaf, "Select a zone in the current layout") source = {"type": request["type"], "zone": leaf["name"]} - for k in ("computer", "profile", "app_class"): + for k in ("computer", "profile", "app_class", "app_title", "desktop_id"): if request.get(k): source[k] = request[k] + if source["type"] == "app": + doc["sources"] = {k: v for k, v in doc["sources"].items() if v.get("desktop_id") != source.get("desktop_id")} if source["type"] == "stream": doc["sources"] = {k: v for k, v in doc["sources"].items() if v.get("computer") != source.get("computer")} @@ -408,7 +444,7 @@ def blocks(self, computer): def restore_refs(self, refs): for ref in refs: workspace = str(ref.get("workspace", "")) - if workspace in self.records or not re.fullmatch(r"[1-9][0-9]*", workspace): + if (workspace in self.records and self.records[workspace]["phase"] != "waiting-session") or not re.fullmatch(r"[1-9][0-9]*", workspace): continue self.records[workspace] = {"workspace": workspace, "document": copy.deepcopy(ref["document"]), "phase": "waiting-workspace", "generation": 0, "operation": uuid.uuid4().hex, @@ -428,7 +464,7 @@ def step(self, record): phase, workspace = record["phase"], record["workspace"] if phase == "waiting-workspace": snap = self.ctl.compositor.snapshot() - if any(w["selector"] == workspace for w in snap["workspaces"]): + if any(w["selector"] == workspace for w in snap["workspaces"]) or (getattr(self.ctl, "generic_scenes", False) and self.has_apps(record["document"])): suppressed = [c for c in self.desired(record) if c in self.ctl.records and not self.ctl.records[c]["desired"]] self.start(record["document"], workspace, snap) restored = self.records[workspace] @@ -437,7 +473,7 @@ def step(self, record): elif self.ctl.now() > record["deadline"]: record.update(phase="needs-attention", error="Workspace did not return during session recovery") return - if phase in ("restored", "needs-attention"): + if phase in ("restored", "needs-attention", "waiting-session"): return wanted = self.desired(record) local_records = [r for r in self.ctl.records.values() if r["assignment"]["workspace"] == workspace] @@ -474,7 +510,22 @@ def step(self, record): snap = self.ctl.compositor.snapshot() live_ws = next((w for w in snap["workspaces"] if w["selector"] == workspace), None) if not live_ws: - return # Local session recovery may still be creating this workspace. + # An empty workspace can disappear while its app is starting. Its + # committed layout rule and content operation still exist; moving + # the eventual window there recreates it without taking focus. + if not record.get("content_applied") and getattr(self.ctl, "generic_scenes", False) and self.has_apps(record["document"]): + sources = [{**value, "zone_id": key} for key, value in record["document"]["sources"].items()] + content = self.ctl.compositor.call("scene_content_apply", {"workspace": workspace, + "layout": "lua:" + record["document"]["layout"], "sources": sources, + "operation": record["operation"], "allow_missing_workspace": True}) + record.update(content_applied=True, results=content["results"], pins=content["pins"]) + if record.get("content_applied"): + results = self.apps.step(record, snap) + zones = {r["zone"] for r in results} + record["results"] = [r for r in record.get("results", []) if r["zone"] not in zones] + results + if any(r["status"] == "needs-attention" for r in results): + record["phase"] = "partial" + return live_spec = snap.get("layouts", {}).get(live_ws["layout"].removeprefix("lua:"), {}).get("spec", {}) if live_spec.get("layout_id") == record["document"].get("layout_id"): document, spec = self.resolve(record["document"]) @@ -488,7 +539,7 @@ def step(self, record): return if not record.get("content_applied") or workspace not in snap.get("scene_content", {}): sources = [{**value, "zone_id": key} for key, value in record["document"]["sources"].items()] - content = self.ctl.compositor.call("scene_content_apply", {"workspace": workspace, "layout": live_ws["layout"], "sources": sources}) + content = self.ctl.compositor.call("scene_content_apply", {"workspace": workspace, "layout": live_ws["layout"], "sources": sources, "operation": record["operation"]}) record["results"], record["pins"] = content["results"], content["pins"] record["content_applied"] = True for computer, target in wanted.items(): @@ -500,10 +551,13 @@ def step(self, record): self.ctl.command({"command": "connect", "computer": computer, "profile": target["profile"], "zone": target["zone"], "workspace": workspace, "scene_internal": True}) record["launched"].append(computer) + app_results = self.apps.step(record, snap) + app_zones = {r["zone"] for r in app_results} + record["results"] = [r for r in record.get("results", []) if r["zone"] not in app_zones] + app_results states = [self.ctl.records.get(c, {}) for c in wanted] problems = any(r.get("observed") in ("needs-attention", "restore-pending", "degraded") or not r.get("desired", True) for r in states) problems = problems or any(r.get("status") == "needs-attention" for r in record.get("results", [])) - if all(r.get("window") for r in states) and not problems: + if all(r.get("window") for r in states) and not problems and not any(r["status"] == "waiting-window" for r in app_results): record["phase"] = "ready" record.pop("error", None) else: diff --git a/test/apps.py b/test/apps.py new file mode 100644 index 0000000..a44962e --- /dev/null +++ b/test/apps.py @@ -0,0 +1,372 @@ +"""Generic app scenes: launch ownership, exact identity, recovery and user moves.""" +import copy +import fcntl +import importlib.util +import json +import multiprocessing +import time +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("scene_fixtures", Path(__file__).with_name("scenes.py")) +f = importlib.util.module_from_spec(spec) +spec.loader.exec_module(f) +from apps import DesktopApps +from scene_service import SceneController +from service import Launchers, match_windows +from ipc import daemon, request +import streams + + +class Desktop(DesktopApps): + def __init__(self, ctl): + super().__init__() + self.ctl, self.launched, self.error = ctl, [], None + + def launch(self, source): + state = json.loads((self.ctl.root / "state.json").read_text()) + assert source["desktop_id"] in state["app_launches"], "launch intent must precede process creation" + self.launched.append(source["desktop_id"]) + + def failure(self, desktop_id): + return self.error + + +class Compositor(f.Compositor): + def call(self, method, args): + if method == "scene_layout": + for ws in self.desktop["workspaces"]: + if ws["selector"] == args["workspace"]: + ws["layout"] = args["layout"] + self.desktop["layouts"][args["layout"].removeprefix("lua:")] = {"spec": args.get("spec", {})} + self.calls.append((method, copy.deepcopy(args))) + return "scene rule" + if method == "scene_app_place": + self.calls.append((method, copy.deepcopy(args))) + window = next(w for w in self.desktop["windows"] if w["address"] == args["address"]) + pin = {k: window[k] for k in ("address", "stable_id", "pid")} + pin.update(zone=args["zone"], before=window.get("pin")) + window.update(workspace=args["workspace"], pin=args["zone"], pin_exclusive=True, floating=False) + return pin + return super().call(method, args) + + +class AppTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + self.data = self.root / "data" + self.apps_dir = self.data / "applications" + self.apps_dir.mkdir(parents=True) + env = patch.dict(os.environ, XDG_DATA_HOME=str(self.data), XDG_DATA_DIRS=str(self.root / "system"), + XDG_STATE_HOME=str(self.root), XDG_CONFIG_HOME=str(self.root / "config")) + env.start() + self.addCleanup(env.stop) + self.desktop_file = self.apps_dir / "remote-desktops-macbook.desktop" + self.desktop_file.write_text('[Desktop Entry]\nType=Application\nName=MacBook\nExec=remote-desktops open macbook\n' + 'X-RemoteDesktops-WindowClass=com.moonlight_stream.Moonlight\nX-RemoteDesktops-WindowTitle=MacBook - Moonlight\n') + self.layouts = f.Layouts() + self.comp = Compositor(self.layouts) + self.now = 100 + self.ctl = self.controller() + self.desktop = Desktop(self.ctl) + self.ctl.scenes.apps.desktop = self.desktop + + def controller(self): + ctl = SceneController(self.root / "hypertile/scenes", self.root / "config/hypertile/scenes.json", self.comp, now=lambda: self.now) + ctl.scenes.layouts = self.layouts + return ctl + + def command(self, action, **kw): + return self.ctl.command({"command": "scene", "action": action, "workspace": "1", **kw}) + + def save(self): + return self.command("save", name="work", document={"version": 1, "layout": "quad", "sources": { + "right": {"type": "app", "desktop_id": self.desktop_file.name}}}) + + def start(self): + self.save() + self.command("apply", name="work") + self.tick() + + def tick(self, n=1): + for _ in range(n): + self.ctl.tick() + self.now += 1 + + def window(self, title="MacBook - Moonlight", address="macbook", workspace="2"): + window = {"address": address, "pid": 7, "stable_id": 22, "class": "com.moonlight_stream.Moonlight", + "initial_class": "com.moonlight_stream.Moonlight", "title": title, "workspace": workspace, + "floating": False} + self.comp.desktop["windows"].append(window) + return window + + def placements(self): + return [v for k, v in self.comp.calls if k == "scene_app_place"] + + def test_installed_entry_supplies_exact_identity(self): + source = self.save()["document"]["sources"]["z-right"] + self.assertEqual(source["app_title"], "MacBook - Moonlight") + self.assertEqual(source["app_class"], "com.moonlight_stream.Moonlight") + self.assertEqual(self.command("catalog")["apps"][0]["name"], "MacBook") + self.assertEqual(self.command("catalog")["computers"], []) + self.assertFalse(self.desktop.launched) + + def test_standalone_service_does_not_take_stream_lock_or_read_computers(self): + legacy = self.root / "hypertile/streams" + legacy.mkdir() + (legacy / "state.json").write_text("not a scene journal") + with (legacy / "writer.lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + ctl = self.controller() + self.assertFalse(ctl.records) + ctl.tick() + self.assertEqual((legacy / "state.json").read_text(), "not a scene journal") + + def test_launch_once_wait_for_final_title_and_reuse_exact_window(self): + other = self.window("Work Laptop - Moonlight", "work") + startup = self.window("Moonlight", "startup") + self.start() + self.tick(5) + self.assertEqual(self.desktop.launched, [self.desktop_file.name]) + self.assertFalse(self.placements()) + startup["title"] = "MacBook - Moonlight" + self.tick(3) + self.assertEqual(len(self.placements()), 1) + self.assertEqual(other["workspace"], "2") + self.assertEqual(startup["workspace"], "1") + self.assertEqual(self.command("current")["phase"], "ready") + self.command("apply", name="work") + self.tick() + self.assertEqual(len(self.placements()), 1) + + def test_existing_window_reused_and_manual_move_survives_restart(self): + window = self.window() + self.start() + self.assertFalse(self.desktop.launched) + window.update(workspace="2", pin=None, floating=True) + self.tick(3) + self.ctl = self.controller() + self.ctl.scenes.apps.desktop = self.desktop + self.tick(3) + self.assertEqual(len(self.placements()), 1) + self.assertEqual(self.command("current")["sources"][0]["status"], "moved") + self.command("apply", name="work") + self.tick() + self.assertEqual(len(self.placements()), 2) + self.assertEqual(window["workspace"], "1") + + def test_close_does_not_relaunch_until_explicit_apply(self): + self.window() + self.start() + self.comp.desktop["windows"].clear() + self.tick(4) + self.assertFalse(self.desktop.launched) + self.assertEqual(self.command("current")["sources"][0]["status"], "closed") + self.command("apply", name="work") + self.tick() + self.assertEqual(len(self.desktop.launched), 1) + + def test_ambiguous_match_neither_launches_nor_places(self): + self.window() + self.window(address="duplicate") + self.start() + self.assertFalse(self.desktop.launched) + self.assertFalse(self.placements()) + self.assertEqual(self.command("current")["phase"], "partial") + + def test_uncertain_launch_survives_restart_timeout_and_explicit_retry(self): + self.start() + self.ctl = self.controller() + self.ctl.scenes.apps.desktop = self.desktop + self.tick(3) + self.assertEqual(len(self.desktop.launched), 1) + self.now += 46 + self.tick() + self.assertEqual(self.command("current")["phase"], "partial") + self.command("retry") + self.tick() + self.assertEqual(len(self.desktop.launched), 2) + + def test_new_scene_does_not_duplicate_pending_launch_or_place_late_app_after_cancel(self): + self.start() + self.command("content", type="app", desktop_id=self.desktop_file.name, zone="left") + self.tick() + self.assertEqual(len(self.desktop.launched), 1) + self.command("cancel") + self.tick() + window = self.window() + self.tick(5) + self.assertFalse(self.placements()) + self.assertEqual(window["workspace"], "2") + + def test_lost_placement_reply_does_not_retry_after_manual_move(self): + window = self.window() + original = self.comp.call + def fail(method, args): + value = original(method, args) + if method == "scene_app_place": + raise RuntimeError("reply lost") + return value + with patch.object(self.comp, "call", side_effect=fail): + self.start() + window["workspace"] = "2" + self.ctl = self.controller() + self.tick(4) + self.assertEqual(len(self.placements()), 1) + self.assertEqual(window["workspace"], "2") + + def test_session_checkpoint_uses_one_launcher_and_preserves_manual_departure(self): + window = self.window() + self.start() + captured = streams.capture(self.comp.snapshot()) + self.assertNotIn("macbook", [w["address"] for w in captured["windows"]]) + self.assertEqual(captured["scenes"][0]["document"]["sources"]["z-right"]["desktop_id"], self.desktop_file.name) + window.update(workspace="2", pin=None) + captured = streams.capture(self.comp.snapshot()) + self.assertIn("macbook", [w["address"] for w in captured["windows"]]) + self.assertEqual(captured["scenes"][0]["document"]["sources"], {}) + recipe = Launchers({}).recipe(window) + self.assertEqual(recipe["argv"], ["gio", "launch", str(self.desktop_file)]) + self.comp.instance = "next-compositor" + self.ctl = self.controller() + self.tick() + self.assertEqual(self.command("current")["phase"], "waiting-session") + self.ctl.command({"command": "session-restore", "scenes": captured["scenes"]}) + self.tick(3) + self.assertEqual(self.command("current")["document"]["sources"], {}) + + def test_scene_preview_pauses_capture_without_any_stream_journal(self): + self.start() + self.ctl.state["browse"]["active"]["1"] = {"token": "preview"} + self.ctl.persist() + with self.assertRaisesRegex(ValueError, "layout preview"): + streams.capture(self.comp.snapshot()) + + def test_missing_empty_workspace_can_return_with_the_app(self): + self.start() + self.comp.desktop["workspaces"].clear() + window = self.window() + self.tick() + self.assertEqual(len(self.placements()), 1) + self.assertEqual(window["workspace"], "1") + + def test_new_assignment_supersedes_pending_placement_on_another_workspace(self): + self.start() + self.comp.desktop["workspaces"].append({"selector": "2", "layout": "dwindle"}) + self.ctl.command({"command": "scene", "action": "apply", "name": "work", "workspace": "2"}) + self.assertEqual(self.ctl.scenes.records["1"]["apps"]["z-right"]["status"], "moved") + self.window() + # The first workspace may observe this late window, but has relinquished placement. + self.ctl.scenes.apps.step(self.ctl.scenes.records["1"], self.comp.snapshot()) + self.assertFalse(self.placements()) + + def test_current_observes_manual_move_before_idle_tick(self): + window = self.window() + self.start() + self.assertEqual(self.ctl.tick_interval(), 30) + window["workspace"] = "2" + self.assertEqual(self.command("current")["sources"][0]["status"], "moved") + self.assertEqual(len(self.placements()), 1) + + def test_recovery_never_matches_other_computer_by_shared_class(self): + saved = self.window() + saved["launch"] = Launchers({}).recipe(saved) + other = {**saved, "address": "other", "title": "Work Laptop - Moonlight"} + matched = {} + match_windows([saved], [other], matched) + self.assertEqual(matched, {}) + final = {**saved, "address": "new"} + match_windows([saved], [other, final], matched) + self.assertEqual(matched, {saved["address"]: "new"}) + + def test_extra_matching_windows_remain_in_normal_checkpoint(self): + self.window() + self.start() + self.window(address="extra") + captured = streams.capture(self.comp.snapshot()) + self.assertEqual([w["address"] for w in captured["windows"]], ["extra"]) + self.assertIn("z-right", captured["scenes"][0]["document"]["sources"]) + + def test_normal_recovery_does_not_claim_the_window_already_placed_by_scenes(self): + saved = self.window() + current = {**saved, "address": "scene-owned", "scene_app": True} + matches = {} + match_windows([saved], [current], matches) + self.assertEqual(matches, {}) + + def test_app_only_scene_recovery_does_not_wait_for_another_app_to_create_workspace(self): + doc = self.save()["document"] + self.comp.desktop["workspaces"].clear() + self.ctl.command({"command": "session-restore", "scenes": [{"workspace": "1", "document": doc}]}) + self.tick(3) + self.assertEqual(self.desktop.launched, [self.desktop_file.name]) + self.window() + self.tick() + self.assertEqual(len(self.placements()), 1) + + def test_daemon_has_independent_socket_and_writer_lock(self): + root, runtime = self.root / "ipc/scenes", self.root / "ipc/runtime" + legacy = self.root / "ipc/streams" + legacy.mkdir(parents=True) + with (legacy / "writer.lock").open("a") as lock, patch.dict(os.environ, HYPRLAND_INSTANCE_SIGNATURE="fake-scenes"): + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + proc = multiprocessing.get_context("fork").Process(target=daemon, args=(root, runtime, self.ctl.config, SceneController)) + proc.start() + try: + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + try: + status = request(runtime, {"command": "status"}, timeout=.2) + break + except (OSError, ValueError): + time.sleep(.02) + else: + self.fail("scene daemon did not start") + self.assertEqual(status["instance"], "fake-scenes") + self.assertEqual((runtime / "control.sock").stat().st_mode & 0o777, 0o600) + with (root / "writer.lock").open("a") as competing: + with self.assertRaises(BlockingIOError): + fcntl.flock(competing, fcntl.LOCK_EX | fcntl.LOCK_NB) + request(runtime, {"command": "stop"}, timeout=1) + proc.join(3) + self.assertEqual(proc.exitcode, 0) + finally: + if proc.is_alive(): + proc.terminate() + proc.join(3) + + def test_launcher_validation_and_xdg_precedence(self): + with self.assertRaisesRegex(ValueError, "not a path"): + self.desktop.resolve({"desktop_id": "../evil.desktop"}) + with self.assertRaisesRegex(ValueError, "disagrees"): + self.desktop.resolve({"desktop_id": self.desktop_file.name, "app_title": "Work Laptop - Moonlight"}) + system = self.root / "system/applications" + system.mkdir(parents=True) + (system / self.desktop_file.name).write_text(self.desktop_file.read_text()) + self.desktop_file.write_text("[Desktop Entry]\nType=Application\nHidden=true\n") + self.desktop.next_scan = 0 + with self.assertRaisesRegex(ValueError, "Install the app"): + self.desktop.resolve({"desktop_id": self.desktop_file.name}) + + def test_launch_uses_desktop_file_without_shell(self): + source = self.save()["document"]["sources"]["z-right"] + with patch("apps.subprocess.Popen") as launch: + DesktopApps().launch(source) + self.assertEqual(launch.call_args.args[0], ["gio", "launch", str(self.desktop_file)]) + self.assertNotIn("shell", launch.call_args.kwargs) + + def test_legacy_stream_workspace_is_rejected_before_any_write(self): + self.save() + self.comp.desktop["streams"] = [{"workspace": "1"}] + with self.assertRaisesRegex(ValueError, "Disconnect legacy"): + self.command("apply", name="work") + self.assertFalse(self.ctl.scenes.records) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/content.js b/test/content.js index 38a041c..13bbbf8 100644 --- a/test/content.js +++ b/test/content.js @@ -90,3 +90,8 @@ assert.strictEqual(controls.reconnect, "") assert.strictEqual(controls.restore, true) assert.strictEqual(C.streamControls({desired: true, observed: "connecting"}).retry, false) assert.strictEqual(C.streamControls({desired: true, observed: "needs-attention"}).retry, true) + +assert.equal(C.label({ type: "app", desktop_id: "remote-desktops-macbook.desktop", app_name: "MacBook" }), "MacBook") +assert.equal(C.state({ type: "app", status: "moved" }).text, "Moved") +assert.match(C.detail({ type: "app", status: "moved" }), /Moved by you/) +assert.match(C.detail({ type: "app", status: "closed" }), /Closed by you/) diff --git a/test/dev.py b/test/dev.py index eef6f34..ecf1c57 100644 --- a/test/dev.py +++ b/test/dev.py @@ -81,6 +81,23 @@ def run(*argv, **kwargs): runner.start() self.addCleanup(runner.stop) + def test_apply_guard_can_coexist_with_remote_desktops_shared_lock(self): + dev.BIN.mkdir(parents=True) + for name in ("hypertile-stream", "hypertile-scenes"): + (dev.BIN / name).write_text("placeholder") + path = self.state / "streams/writer.lock" + path.parent.mkdir(parents=True) + with path.open("a") as remote: + fcntl.flock(remote, fcntl.LOCK_SH | fcntl.LOCK_NB) + with dev.stopped_session(): + with path.open("a") as legacy: + with self.assertRaises(BlockingIOError): + fcntl.flock(legacy, fcntl.LOCK_EX | fcntl.LOCK_NB) + self.assertFalse(any(c[-2:] == ["hypertile-stream", "stop"] or + (c[0].endswith("hypertile-stream") and c[-1] == "stop") for c in self.commands)) + with (self.state / "scenes/writer.lock").open("a") as scene: + fcntl.flock(scene, fcntl.LOCK_EX | fcntl.LOCK_NB) + def test_link_preserves_dirty_checkout_and_is_idempotent(self): (self.plugin / ".git").mkdir(parents=True) (self.plugin / ".git/HEAD").write_text("original HEAD") diff --git a/test/scenes.lua b/test/scenes.lua index c10f4ff..1208a89 100644 --- a/test/scenes.lua +++ b/test/scenes.lua @@ -12,7 +12,7 @@ local calls, timers = {}, {} local function tag(kind) return function(args) args.kind = kind; return args end end hl = { get_windows = function() return windows end, get_workspaces = function() return { ws } end, window_rule = function() end, timer = function(callback) timers[#timers + 1] = callback end, - dsp = { window = { resize = tag("resize") }, focus = tag("focus"), send_key_state = tag("key") }, + dsp = { window = { resize = tag("resize"), move = tag("move"), float = tag("float"), fullscreen_state = tag("fullscreen") }, focus = tag("focus"), send_key_state = tag("key") }, dispatch = function(args) calls[#calls + 1] = args end } local session = require("hypertile-session") package.loaded["hypertile-bridge"] = { @@ -63,4 +63,43 @@ ok = pcall(session.stream_shortcut, { computer = "laptop", action = "clipboard" assert(not ok and #calls == 3, "shortcut refuses a reused address") ok = pcall(engine.compile, { columns = { { name = "a", id = "same" }, { name = "b", id = "same" } } }) assert(not ok, "duplicate zone identities are refused") +-- Ordinary apps share no stream reservation or launch rule. Match and move +-- one final window, rejecting a second copy and recycled compositor identities. +windows[2] = nil +windows[1].title = "Document" +request.operation = "app-operation" +session.scene_content_apply(request) +local place = { workspace = "1", layout = "lua:test", operation = "app-operation", zone_id = "b", zone = "middle", + address = "a", stable_id = 1, pid = 11, app_class = "editor", app_title = "Document" } +local count = #calls +place.stable_id = 999 +assert(not pcall(session.scene_app_place, place) and #calls == count, "reused address cannot receive app placement") +place.stable_id = 1 +windows[2] = { address = "extra", stable_id = 44, pid = 77, class = "editor", title = "Document", workspace = ws, mapped = true } +assert(not pcall(session.scene_app_place, place) and #calls == count, "atomic placement rejects a late duplicate") +windows[2] = nil +local pin = session.scene_app_place(place) +assert(pin.zone == "middle" and engine.state.test.pins.a == "middle") +assert(engine.state.test.exclusive_pins.a, "generic app occupies the requested zone") +local ws2 = { id = 2, name = "2", tiled_layout = "lua:test" } +windows[1].workspace = ws2 +count = #calls +session.scene_app_place(place) +assert(#calls == count, "a repeated operation never moves a departed app back") +session.scene_content_apply(request) +assert(#calls == count, "repeated content apply preserves the original operation") +session.scene_clear({ workspace = "1" }) +assert(engine.state.test.pins.a == "middle", "clearing a scene preserves a window moved to another workspace") +assert(not pcall(session.scene_app_place, place) and #calls == count, "superseded operation cannot place a late window") +windows[1].workspace = ws +request.operation = "new-app-operation" +session.scene_content_apply(request) +place.operation = request.operation +hl.get_workspaces = function() return {} end +local before = #calls +session.scene_app_place(place) +assert(#calls > before, "a pending app can recreate its vanished empty workspace") +local move +for i = before + 1, #calls do if calls[i].kind == "move" then move = calls[i] end end +assert(move and move.workspace == "1" and move.follow == false, "app placement does not take focus") print("scene adapter: all checks passed") diff --git a/test/stream.lua b/test/stream.lua index e85a6cf..f867012 100644 --- a/test/stream.lua +++ b/test/stream.lua @@ -58,7 +58,7 @@ session.stream_assign(source) assert(engine.state.test.reservations["1"].remote == "a") for _, call in ipairs(calls) do assert(call.kind ~= "focus", "placement never steals focus") - if call.kind == "move" then assert(call.silent == true and call.window == "address:a") end + if call.kind == "move" then assert(call.follow == false and call.window == "address:a") end end calls = {} source.placed = true diff --git a/uninstall.sh b/uninstall.sh index 975b576..e37dc5d 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -63,6 +63,10 @@ if [[ -x "$bin/hypertile-stream" ]]; then "$bin/hypertile-stream" stop >/dev/null 2>&1 || true fi +if [[ -x "$bin/hypertile-scenes" ]]; then + "$bin/hypertile-scenes" stop >/dev/null 2>&1 || true +fi + # One backup per edited config file, overwritten on each edit. backup() { cp "$1" "$1.hypertile.bak" @@ -140,6 +144,9 @@ done rm -f "$bin/hypertile-ctl" rm -f "$bin/hypertile-session" "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/session/service.py" \ "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/session/streams.py" +rm -f "$bin/hypertile-scenes" "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/stream/scene_service.py" \ + "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/stream/apps.py" \ + "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/stream/ipc.py" rm -f "$bin/hypertile-stream" "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/stream/controller.py" \ "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/stream/mac_display.py" \ "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/stream/scenes.py" \