diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e20dad..757610f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,9 +14,17 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@v4 - - name: Windows PowerShell display policy and journal + # The implementation now belongs to Remote Desktops. Keep the required + # native policy check on its immutable, validated extraction revision. + - uses: actions/checkout@v4 + with: + repository: jdvmi00/remote-desktops + ref: 30d795c83164f812ec70a4374e68926e1cb39d9c + path: remote-desktops + persist-credentials: false + - name: Migrated Windows PowerShell display policy and journal shell: powershell - run: .\stream\windows\Test.ps1 -PolicyOnly + run: .\remote-desktops\remote_desktops\windows\Test.ps1 -PolicyOnly test: runs-on: ubuntu-latest timeout-minutes: 20 @@ -29,7 +37,7 @@ jobs: - name: Scripts run: shellcheck install.sh uninstall.sh - name: Development helper - run: python3 test/dev.py + run: python3 test/dev.py && python3 test/upgrade.py - name: Engine run: lua test/harness.lua - name: Window navigation @@ -38,12 +46,8 @@ jobs: run: lua test/bridge.lua - name: Session recovery run: python3 test/session.py && lua test/session.lua - - 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 && 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 + run: python3 test/scenes.py && python3 test/apps.py && lua test/scenes.lua && lua test/swap.lua && node test/content.js - name: Managed layout browsing run: python3 test/browse.py && node test/browse.js - name: Overlay geometry diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb62de..de20c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Remote connections now belong to Remote Desktops. Remove Hypertile's legacy + controller, host adapters, display scripts, and remote controls. Scenes and + session recovery use ordinary installed apps; pinned swaps remain generic. + Upgrade checks preserve unresolved host recovery and user configuration. + - Session recovery: batched automatic checkpoints with durable publication and previous generations; named sessions; protected partial restores; supported app relaunch; workspace/layout, native window order, pins, sizing, floating diff --git a/README.md b/README.md index 6eaa1fa..1c87e41 100644 --- a/README.md +++ b/README.md @@ -377,8 +377,10 @@ not own remote connections or host display settings. 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. +Remote connections and host recovery now belong to +[Remote Desktops](https://github.com/jdvmi00/remote-desktops). Upgrade checks +require legacy connections to be disconnected and restored before removing +their old runtime files; saved configuration and journals are preserved. ## License diff --git a/bin/hypertile-ctl b/bin/hypertile-ctl index 6afa76e..df77132 100755 --- a/bin/hypertile-ctl +++ b/bin/hypertile-ctl @@ -64,12 +64,8 @@ JSON exchanged with editors: {"name": "...", "spec": {...}} monitor size, reserved edges, gaps, and layout area workspaces [--json] every workspace with its monitor and layout windows [--json] open windows (class, title, workspace) - computers [--json] configured remote computers and pairing references scene [...] list, save, apply, current, restore, retry, content; see docs/SCENES.md - stream [...] probe, connect, focus, disconnect, status, retry, - restore, release, swap, reconnect, local, quality, - measure, readability; see docs/STREAMS.md session [name] status, save , restore [name], freeze, resume, stop, logout, reboot, shutdown; see docs/SESSIONS.md default [name|BUILTINS] [--no-reload] @@ -448,16 +444,14 @@ end local argv = { ... } local cmd = table.remove(argv, 1) -if cmd == "stream" or cmd == "computers" or cmd == "scene" then - local entry = cmd == "scene" and "hypertile-scenes" or "hypertile-stream" +if cmd == "scene" then + local entry = "hypertile-scenes" 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 if #argv == 0 then argv[1] = "current" end end - if cmd == "stream" and #argv == 0 then argv[1] = "status" end for _, value in ipairs(argv) do words[#words + 1] = value end for i, value in ipairs(words) do words[i] = "'" .. value:gsub("'", "'\\''") .. "'" end local ok, _, code = os.execute(table.concat(words, " ")) diff --git a/bin/hypertile-scenes b/bin/hypertile-scenes index 62155f4..07cf8b8 100755 --- a/bin/hypertile-scenes +++ b/bin/hypertile-scenes @@ -8,5 +8,5 @@ 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__") +sys.path.insert(0, str(root / "scenes")) +runpy.run_path(str(root / "scenes/scene_service.py"), run_name="__main__") diff --git a/bin/hypertile-stream b/bin/hypertile-stream deleted file mode 100755 index b6424ab..0000000 --- a/bin/hypertile-stream +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env python3 -"""Entry point for the installed stream controller (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/controller.py"), run_name="__main__") diff --git a/dev b/dev index be91ebb..7c4d44d 100755 --- a/dev +++ b/dev @@ -15,6 +15,8 @@ import tempfile import time ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT / "session")) +from upgrade import check_legacy, obsolete, cleanup CONFIG = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") DATA = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share") STATE = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "hypertile" @@ -56,9 +58,8 @@ 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")), *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"))], + "session": [ROOT / "bin/hypertile-session", *sorted((ROOT / "bin").glob("hypertile-scenes")), + *sorted((ROOT / "session").glob("*.py")), *sorted((ROOT / "scenes").glob("*.py"))], "shell": [ROOT / "manifest.json", *sorted(p for p in (ROOT / "plugin").rglob("*") if p.is_file())], } @@ -73,7 +74,7 @@ def destination(path): relative = path.relative_to(ROOT) if relative.parts[0] == "bin": return BIN / path.name - if relative.parts[0] in ("session", "stream"): + if relative.parts[0] in ("session", "scenes"): return DATA / "hypertile" / relative return CONFIG / "hypr" / path.name @@ -94,8 +95,7 @@ def validate(sources): lua = sources["lua"] + sources["cli"] + sorted((ROOT / "layouts").glob("*.lua")) run("lua", "-", *lua, input="for _, path in ipairs(arg) do assert(loadfile(path)) end\n") for path in sources["session"] + [ROOT / "dev"]: - if path.suffix not in (".ps1", ".cs"): - compile(path.read_bytes(), str(path), "exec") + compile(path.read_bytes(), str(path), "exec") run("omarchy", "plugin", "validate", ROOT) qmlformat = shutil.which("qmlformat") or "/usr/lib/qt6/bin/qmlformat" if Path(qmlformat).is_file(): @@ -182,7 +182,7 @@ def stopped_session(): run(BIN / "hypertile-scenes", "stop", timeout=10) wait_for(scene_available, "scene service to stop") stream_lock = None - if (BIN / "hypertile-stream").exists(): + if (BIN / "hypertile-stream").exists() or (STATE / "streams/state.json").exists(): path = STATE / "streams/writer.lock" path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) stream_lock = stack.enter_context(path.open("a")) @@ -202,6 +202,7 @@ def stopped_session(): 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") + check_legacy(STATE) path = STATE / "sessions/writer.lock" path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) with path.open("a") as lock: @@ -298,6 +299,13 @@ def apply(force): saved.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(target, saved) atomic_write(target, content, 0o755 if path.relative_to(ROOT).parts[0] == "bin" else 0o644) + if "session" in changed: + for target in obsolete(BIN, DATA): + if target.exists(): + saved = backup / "retired" / (Path("bin") / target.name if target.parent == BIN else target.relative_to(DATA)) + saved.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(target, saved) + cleanup(BIN, DATA) if "lua" in changed: reload_lua() finally: diff --git a/docs/RELEASING.md b/docs/RELEASING.md index f737ec6..6103fe1 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -22,6 +22,9 @@ remote-stream and scene work is not part of this candidate. 3. Push the feature branch and open a PR targeting `develop`. 4. Wait for `test` and `windows-display-policy` to pass, then merge the PR. +The `windows-display-policy` job runs the extracted policy suite from an immutable +Remote Desktops commit on Windows; host code is no longer duplicated here. + Both integration branches require passing checks on an up-to-date PR; no additional reviewer is required for this solo-maintainer repository. Force pushes and deletion are disabled. The extra lock on `main` prevents even a diff --git a/docs/SCENES.md b/docs/SCENES.md index b278d1f..6df00ac 100644 --- a/docs/SCENES.md +++ b/docs/SCENES.md @@ -146,17 +146,16 @@ 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. +invalid until migrated. Upgrade and uninstall refuse to remove recovery tools +while any legacy connection is desired or has a pending host journal, including +when its controller is stopped. Finish recovery with the previously installed +version before upgrading. Configuration and state files are kept. + +Hypertile no longer installs `hypertile-stream`, host adapters, Windows display +scripts, connection controls, or quality measurements. The standalone Scenes +service lives under `scenes/`; it only manages layouts and app placement. +Connection, input capture, audio, and host display controls belong to +[Remote Desktops](https://github.com/jdvmi00/remote-desktops). ## Validation @@ -171,4 +170,9 @@ 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. +Both MacBook and the Windows work laptop were subsequently validated live with +Remote Desktops launchers and the saved two-app scene. Workspace movement, +launcher reuse, explicit scene reapplication, and Windows reconnection passed; +the owner confirmed video, mouse, and keyboard on both computers. These checks +preceded removal of the legacy code; removal is covered by the generic app, +scene, session, swap, preview, and upgrade regression suites. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 58f0d9e..58b45dc 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -179,7 +179,10 @@ service during recovery in the same compositor does not relaunch already attempted apps. Automatic saving resumes only after all saved windows have matched and settled, or after an explicit `resume`. -Workspace [scenes](SCENES.md) are checkpointed as versioned definitions alongside -stream references. The stream controller restores their content after the local -workspace returns; it retains disconnect decisions independently of older -checkpoints. Scene definitions do not include transient compositor window IDs. +Workspace [scenes](SCENES.md) are checkpointed as versioned definitions. The +independent scene service places their apps, including on an otherwise empty +workspace. Moved or closed app assignments are omitted from recovery without +changing saved scene defaults. Normal app recovery handles moved windows using +their exact launcher identity. Old remote assignments generate a migration +message and are never sent to a connection controller. Scene definitions do not +include transient compositor window IDs. diff --git a/docs/STREAM-QUALITY.md b/docs/STREAM-QUALITY.md deleted file mode 100644 index c212c23..0000000 --- a/docs/STREAM-QUALITY.md +++ /dev/null @@ -1,112 +0,0 @@ -# Stream quality and interaction - -The **Performance** panel (Scenes tab, selected zone, More controls) reports -connection timing, completed Moonlight decoder statistics, and explicit -readability assessments. Reconnect sits beside the zone's stream controls and -Focus a local window under More controls. - -```bash -hypertile-ctl stream quality macbook --json -hypertile-ctl stream reconnect macbook -hypertile-ctl stream measure macbook --seconds 30 -hypertile-ctl stream readability macbook readable -hypertile-ctl stream local macbook -``` - -## Collecting evidence - -An existing stream needs one reconnect after installing this version to enable -its new logger. `reconnect` gracefully closes only the owned local Moonlight -window, waits for its logger, and starts the same profile in the same zone. It -keeps the reservation and original host restoration journal. Pairing, display, -power and assignment checks still run. Host apps stay open. Repeated requests -during the reconnect reuse the operation; Disconnect or a superseding scene -cancels it. A client that ignores graceful close is terminated after a bounded -wait; its final statistics may then be unavailable. - -`measure` schedules this same reconnect after 10–300 seconds of continued use. -The default is 30 seconds. It returns immediately; the controller owns the timer. -Use `quality` to inspect the result. A disconnect, profile change, client exit or -reboot cancels an outstanding collection. Restarting the controller in the same -boot preserves it. The timer never creates another connection after cancellation. - -Stock Moonlight Qt 6.1 logs its FFmpeg summary when that decoder is destroyed. -**These are completed decoder-segment statistics, not a live 30-second sample.** -They include activity since that decoder started, potentially before collection -was scheduled. For a repeatable comparison, reconnect, wait for the view to be -ready, use the same document/workload and geometry, then collect. Resizing or -changing display conditions can create a new decoder segment. The report keeps -the latest complete summary, rather than averaging incompatible segments. - -The parser accepts only known numeric fields in the expected summary format. -Raw logs, request URLs, clipboard text and screen content are not retained. -Unknown versions, incomplete summaries, absent measurements and invalid values -remain unavailable; they do not become zero. Collection does not toggle remote -debug logging or require a patched Moonlight/Sunshine build. - -## Reading the results - -| Result | Meaning | -| --- | --- | -| Window-ready time | Monotonic elapsed time from the accepted connect/reconnect request until the owned final window is placed. It does not measure first-frame presentation. | -| Stages/work | Elapsed stage boundaries and time spent doing controller work, useful for separating startup waits from host checks. | -| Decode, queue, render | Moonlight's average decoder, frame queue and rendering times for the completed segment. Rendering includes its V-sync wait. | -| Network RTT | The RTT value reported in the summary, not a capture-to-display measurement. | -| Network/jitter loss | Moonlight's separate percentages for missing network frames and frames discarded by its pacer. | -| Host processing | Reported when supplied by the host; this includes more than encoding and is not renamed “encode latency.” | -| Encoding-only/end-to-end | Unavailable from this telemetry. External testing is still required for end-to-end latency. | - -See the [tested Moonlight formatter](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/video/ffmpeg.cpp) -and [Moonlight's metric definitions](https://github.com/moonlight-stream/moonlight-docs/wiki/Frequently-Asked-Questions). - -Readability is an explicit assessment: `readable`, `too-small`, or `blurry`. -It applies to the recorded profile settings and view dimensions. Resizing the -window invalidates its use as a current assessment. Check ordinary document -text, code, punctuation and scrolling at your actual zone size; resolution alone -does not establish readability. The UI records your choice without reading the -remote document. - -At most 20 runs per computer are kept in the private controller state, with the -latest five exposed in history. Results include requested settings, client -version, view size, observed host mode and timestamps. Completed results used -for advice must match the current settings fingerprint; reusing a profile name -for different settings does not make old results applicable. Advice uses simple -thresholds (over 0.5% loss or decoding longer than one requested frame interval) -to suggest a comparison, and never changes the profile automatically. - -## Returning to local input - -**Return locally** releases compositor input capture and focuses the last -observed local window on the same workspace. It validates window identity and -never takes focus away from an already focused local app. If that local window -closed, it uses another local window on that workspace. With no local window, -use the existing Toggle capture / Ctrl+Alt+Shift+Z control. - -Stock Moonlight absolute input already supports leaving a window with the -pointer; its drag handling deliberately retains mouse input while buttons are -held. Relative input requires explicit capture release. Hypertile adds no global -pointer forwarding, edge polling, synthetic crossing clicks, or automatic focus -switching. The separate keyboard-capture profile remains an explicit choice for -Mac Command/Windows shortcuts. See the -[client input implementation](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/input.cpp). - -## A3 decisions - -Startup and teardown states now poll every 200 ms, while steady streams poll -once per second. This removes measured inter-stage waiting without skipping -host validation, increasing network retry rates, or changing the restoration -transaction. The existing 2/5/15-second network retry backoff and host-health -probe interval are preserved. - -No quality presets or automatic profile changes were added. The initial Mac -comparison supports reducing controller wait time; it does not establish a -better codec, bitrate, host mode or meeting policy. Automatic selection would -need repeated comparisons for the relevant host, network, view size and audio -policy, plus a benefit sufficient to justify reconnecting the view. Existing -scene/profile selection stays explicit. The measured improvement uses the stock -client; no upstream patch or fork is needed for it. - -The [validation record](STREAMS-VALIDATION.md) contains the live baseline, -comparison and hardware limits. Physical edge-crossing behavior in relative -capture, Windows measurements, and encoding-only/end-to-end measurements remain -separate validation work. diff --git a/docs/STREAMS-VALIDATION.md b/docs/STREAMS-VALIDATION.md deleted file mode 100644 index fe23d56..0000000 --- a/docs/STREAMS-VALIDATION.md +++ /dev/null @@ -1,316 +0,0 @@ -# Remote desktop and scene implementation checks - -Implementation and local installation: 2026-09-04. Usage and limitations are in -[STREAMS.md](STREAMS.md). This records what was exercised; the full hardware -acceptance matrix is not implied by the automated tests. - -## Automated checks - -- 36 Python stream tests: configuration and paired identity, serialized launch - cancellation, repeated requests, process ownership, restart reconciliation, - delayed workspace creation, retained unresolved sources, offline local session - recovery, bounded retries, close/EOF ordering, display rollback and conflicts, - nominal refresh rounding, host-health handling and private log extraction; - source swap persistence, lost-reply recovery, cancellation, stale identities, - two-source exchanges and rejection of old-compositor window references. -- Lua stream adapter checks: reservations, overflow, collapsed empty zones, - workspace isolation, final-window identity, targeted placement without focus, - temporary window rules and release; source/local and source/source swaps, - subsequent local swaps, uncapped fill, overflow and stale-target rejection. -- Directional navigation checks include routing managed swaps through the source - controller while retaining native swaps for ordinary unpinned local windows. -- Existing tests: 14 Python session tests, 3 development-helper tests, Lua session - adapter, 136 engine checks, 132 bridge/CLI checks, 32 geometry checks and 110 - editor checks. -- ShellCheck on install/uninstall, Python/Lua syntax validation, plugin validation, - and a live Hyprland reload without configuration errors. - -## Live results - -| Exercise | Result | -| --- | --- | -| Mac connection | Final Moonlight window in the requested zone, normal tiled state, visible desktop and keyboard input. | -| Windows connection | Paired desktop rendered in a second requested zone; external display management reported. | -| Repeated/concurrent connect | Four concurrent Mac requests returned the same operation and PID. | -| Background connection | Mac connected to workspace 1 while a local terminal remained active on workspace 2; workspace stayed 2 throughout startup and placement. | -| Controller update/reload | Repeated updates and Hyprland config reloads retained existing Mac/Windows PIDs; no duplicate streams. | -| Session capture | Both source references were saved; zero managed Moonlight windows appeared in generic app recovery. | -| Mac mode transaction | Original mode persisted before a change; requested and actual mode read back; restoration returned the original mode. | -| Manual mode change | Disconnect preserved the different mode and retained a restoration conflict. The test then returned the display to its original mode and cleared the journal through `restore`. | -| Normal window close | Both hosts disconnected and released their reservations. Mac host-mode restoration completed and cleared its journal. | -| Existing Mac launcher | Updated to call the managed controller; used to reconnect the final Mac session. | -| Pointer scaling | With HiDPI off, a measured local pointer offset mapped to the expected Mac coordinates. | -| Source swap | In the active quad layout, the Mac and local terminal exchanged zones and swapped back through the directional shortcut handler. The reservation and saved source assignment followed the Mac; all other window positions and focus remained unchanged. | -| Restart after swap | Restarting the controller while the Mac was in its new zone retained that assignment and Moonlight PID. Swapping back restored the original arrangement. No host-mode or journal change. | - -The tested client is Moonlight Qt 6.1.0. The Mac runs Sunshine -2026.516.143833 and BetterDisplay 4.3.4. Its physical Dell display is connected -with AC power. The final Mac profile has a 1920×1080 logical/rendered desktop, -2560×1440 stream, HEVC, 60 FPS, 60 Mbps, SDR, standard chroma and absolute input. -The Windows profile requests 2560×1600 with the same video baseline; host display -settings are externally managed. - -Two compatibility issues changed the implementation: - -1. BetterDisplay UUID-only queries also returned a default display group. - Physical display operations now explicitly include `type=Display`, and - activity is checked with CoreGraphics. The `connected` getter failed on this - installation and is not used. -2. Nominal 60 Hz modes sometimes read back as 59.95 Hz. Mode comparisons allow - less than 0.15 Hz variation while recording actual values. Resolution and - HiDPI must still match. On the tested Sunshine build, HiDPI absolute input - produced a 2× pointer offset; the default profile keeps the same readable - logical size with HiDPI off. A relative-input HiDPI profile is optional. - -## Still requiring hardware validation - -- Deliberate network interruption and recovery, host sleep/wake, and a full - compositor logout/restart. Their controller failure/recovery paths are covered - by controlled tests; live config reload is not the same as compositor restart. -- Opening/closing the Mac lid during a managed transaction, battery-only use, - and removing the Dell. Earlier prototype lid-closed evidence does not establish - those additional combinations. -- Relative-input HiDPI behavior, Windows modifier/clipboard behavior, audible - audio routing, Teams camera/microphone use and virtual displays. Mac modifier - and clipboard results are recorded below. -- End-to-end latency and sustained presentation measurements. A3's short Mac - decoder summaries below provide limited frame-loss evidence. - `window-ready` and decoder initialization deliberately do not claim verified - frame presentation. - -One fill zone must remain available for local windows. The background test used -an actual local window: Hyprland can remove a transient empty workspace when a -startup window disappears. The Mac display adapter currently uses an approved -SSH multiplex session; future connections need that session or independently -configured noninteractive SSH authentication. The active stream does not require -continuous SSH connectivity to keep rendering. - -## A2 scenes and everyday controls - -Implemented and installed locally on 2026-09-04. See [SCENES.md](SCENES.md). - -Automated checks add 22 Python scene/input/audio tests, Lua scene adapter checks, -and JavaScript Content/identity checks. They exercise stable IDs, renamed and -missing references, queued rename handling, duplicate computer/app rejection, -Empty reservations, local app ambiguity, retained client movement, profile -switching, supersession, explicit disconnects, controller restart, offline -partial application, baseline restoration, clipboard capability gating, optional -system-key capture, and muting only the owned client's audio. All existing -stream, session, navigation, engine, bridge, editor, geometry and development -helper suites also pass. A session restoration argument-order error found during -integration is fixed and covered by a regression check. Lua scene layout changes -apply directly in the compositor; rule-file persistence runs in the controller, -avoiding recursive compositor IPC. - -| Live exercise | Result | -| --- | --- | -| Save/apply current desktop | Saved `desktop` using persistent layout and leaf identities. Applying it retained the existing Mac client. | -| Directional swapping with a saved scene | Reproduced the A2 zone-ID regression, fixed assignment validation, and tested the actual keyboard swap handler down and back in the active quad layout. Both windows moved; focus, unrelated windows, client PID, host journal and saved scene contents stayed unchanged. Regression tests cover scene state, restart, and stale zone identities. | -| Content picker movement | Moved the Mac to another quad zone through overlay IPC, then applied `desktop` to restore it. The same PID and host journal were retained. | -| Empty and local app | On a temporary workspace, reserved an Empty zone and assigned its unique local terminal to another zone; the main desktop and Mac connection were unaffected. Restore removed the reservation and the temporary window was closed. | -| Controller/config updates | Reconciled scene content and retained ready client ownership across service updates and Hyprland reloads. | -| Profile switch | Switched the Mac between desktop and desktop-capture, with display restoration before relaunch, then reapplied the saved desktop scene. | -| Statistics | The overlay shortcut displayed Moonlight's statistics over the actual stream; a second invocation hid them. | -| Mac Command key | With desktop-capture and capture activated, physical Super+A selected the temporary document’s text and replacement typing replaced all of it. Merely focusing a newly opened client did not activate capture. | -| Mac ordinary modifiers | Option+Left moved by a word in a temporary TextEdit document; a physical Shift key event produced uppercase input. | -| Mac clipboard typing | Failed in a temporary document. Confirmed the installed Sunshine version's macOS Unicode input method is unimplemented. The CLI rejects the action and Content displays the reason. | -| Host-headset audio | Process-specific muting and missing-output handling tested with controlled audio objects. Candidate meeting profiles configured; audible playback and a real call remain unverified. | - -Mac input checks used disposable local TextEdit documents, synthetic text, and -conditional clipboard restoration. No Teams content or call was inspected or -started. The temporary documents were removed. Automatic clipboard sharing is -not implemented. - -The clipboard limitation is in the -[tested Sunshine Mac implementation](https://github.com/LizardByte/Sunshine/blob/v2026.516.143833/src/platform/macos/input.cpp). -[Moonlight's shortcut implementation](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/keyboard.cpp) -sends that UTF-8 text path and drops GUI/Command keys when system-key capture is -inactive. This is distinct from ordinary key input and from the client receiving -a shortcut. - -A representative Teams call must still verify camera, microphone, host-headset -or continuous playback, focus changes, and echo. Windows shortcut/clipboard and -live audio-policy checks, the A1 hardware checks above, and a complete compositor -restart remain outstanding. These limitations are not inferred from desktop -video readiness or the presence of an audio stream. - -## A3 interaction and quality - -Implemented and installed locally on 2026-09-04. See -[STREAM-QUALITY.md](STREAM-QUALITY.md) for collection semantics and controls. - -Automated coverage adds 14 Python quality/reconnect tests, Lua identity/focus -checks and JavaScript metric formatting checks. They cover typed summary -parsing, truncated/invalid output, unsupported versions, monotonic timing, -bounded history, exact profile/size matching, idempotent reconnect, cancellation, -controller restart, late logger output, compositor recovery identity, scheduled -collection and unchanged retry backoff. Existing stream, scene, session, -navigation, engine, bridge, geometry, editor and development checks pass. - -| Live exercise | Result | -| --- | --- | -| Baseline reconnect | 17.56 s from accepted request to the final window being placed, using one-second transition polling. | -| Faster transition polling | Two repeats at 200 ms polling took 13.72 s and 13.79 s. Assignment, profile and original host restoration journal were retained. | -| Scheduled collection | A 30-second collection survived a controller update and completed; a second 10-second collection also completed. Each briefly reconnected only the local view. | -| Decoder summaries | Average decode 0.11 ms in both summaries; rendered 59.97 and 59.93 FPS. Network frame loss 0.27% and 0.51%; jitter loss 0% and 0.03%. RTT 3 ms in both. | -| Return locally | Focused the stream, then returned to the same local terminal with input capture released. | -| Readability | Synthetic text in a disposable Mac TextEdit document, Menlo 14 point, was legible in the current 2218×1246 tile. Recorded as readable for this profile and size; the document was removed and the previous Mac app restored. | -| Performance UI | Expanded the panel on the live Mac source and checked metric labels, wrapping, collection controls and readability choices; no QML errors. | -| Swap regression after installation | Swapped the focused local window and Mac right and back through the actual directional handler. Focus, other windows, client PID, profile and host journal were unchanged. Restored the scene's unmodified state with identical saved bytes. | - -The approximately 3.8-second improvement is a small local comparison, not a -statistical guarantee or a network-recovery measurement. In the first faster -run, stages reached preflight at 0.52 s, preparing at 1.61 s, launch at 2.20 s, -connecting at 2.47 s and window-ready at 13.72 s. Paired identity, power and -display checks still ran; most remaining startup time was in Moonlight. - -The decoder summaries cover activity since their decoder segment began, not -just the scheduled 10/30-second wait. Queue delay was 0.01 ms; rendering was -1.37/1.34 ms. The tested host did not supply host-processing latency. Pure -encoding and end-to-end latency remain unavailable. The second frame-loss -result suggests comparing bitrate under the same workload; it does not justify -an automatic change or a new preset. - -No client fork or patch was needed. Physical pointer-edge behavior, relative -capture, Windows quality, sustained workloads, sleep/network recovery and call -hardware still need their separate checks. No Teams content or call was read -or started during this validation. - -## Layout browsing with assigned content - -Fixed and validated on 2026-09-05. Layouts browsing now moves the actual windows -on a workspace with scene assignments or a connected stream. The controller -holds the original layout while a temporary preview is active, so reconciliation -does not mistake the preview for a deleted stream zone. Closing the overlay or -returning to Scenes restores the committed layout; saved source references and -host settings are unchanged. - -Eight Python tests cover preview/cancel, late requests, heartbeat expiry, -controller restart, superseding scenes, disconnect, checkpoint protection and -restoration failures. JavaScript tests exercise the overlay's actual managed and -ordinary browsing routes, serialization and teardown. Existing suites pass. - -Live checks on the active quad layout moved both local windows and the Mac view, -kept a preview alive through heartbeats, returned through the Scenes tab, and -rapidly selected layouts before immediately closing. The exact initial window -positions returned. Focus, stream PID, assignment, operation, profile, journal, -saved scenes and workspace rule files were retained. Restarting the controller -during a live preview also restored the original layout before cleanup, retaining -the same Mac client process. - -## Windows display recovery - -Implemented and installed on the work laptop on 2026-09-05. The Windows console -helper owns display topology, with Sunshine's display automation disabled. The -desktop and meeting profiles use the managed Windows adapter. See -[Windows display recovery](WINDOWS-DISPLAY-RECOVERY.md) for setup and operation. - -| Live exercise | Result | -| --- | --- | -| Connect and disconnect | The dedicated virtual display was the only active output during streaming at 2560×1600. Disconnect restored the built-in panel at 1920×1200, disabled the virtual output and cleared the local recovery journal. The user confirmed the panel lit. | -| Delayed cancelled preparation | Replaying a cancelled preparation was rejected with `operation-cancelled`; the physical display stayed active. | -| Recovery without the controller | Paused the Linux controller and terminated its Moonlight client. The laptop helper restored the internal panel without receiving a restore request. The Linux journal still contained the original preparation until the controller resumed and reconciled it. | -| Closed lid, then undock | Started from the Dell-only desktop with the lid closed. While streaming, the user unplugged the dock. Disconnect left recovery pending because Windows exposed no physical display. Opening the lid automatically restored the built-in panel at 1920×1200, disabled the virtual display and cleared the journal. The user confirmed the screen came back. | - -Eight Python tests cover ownership, acknowledgement loss, verified restoration, -transport identity, and continued recovery after disconnect or failure. Eighteen -checks on Windows PowerShell 5.1 cover display selection, Sunshine connection -events and atomic journal replacement; the native display wrapper compiled and -its read-only console inventory succeeded. Existing stream, scene, quality, -placement and development checks pass. - -The connection parser was checked against this laptop's Sunshine build -2026.516.143833. It counts connection events separately from stream-worker slots, -so an old disconnected worker does not prevent a subsequent recovery. Unknown -activity blocks restoration. Normal window-close handling is covered by the -controller tests; the live offline test exercised client termination. Pre-login, -lock-screen, sleep/wake, reboot and a physical network outage still require -separate validation. - -## Mac pointer scaling after a mode change - -Fixed and installed on 2026-09-05. Switching the Dell from 1920×1080 HiDPI to -1920×1080 without HiDPI left the stream with an incorrect pointer range. -[Sunshine caches the macOS input scale at startup](https://github.com/LizardByte/Sunshine/blob/v2026.516.143833/src/platform/macos/input.cpp#L525). -The adapter now verifies each changed mode and restarts Sunshine afterward, -including when the capture display ID is unchanged and when restoring a mode. - -The regression test covers both HiDPI transition directions with an unchanged -capture output. All 37 stream tests and the Windows adapter, scene and quality -tests pass. The live Mac reconnected in its original quad tile with a 1:1 display -coordinate/pixel ratio; the user confirmed the pointer reaches the full window -and clicks correctly. This validates the desktop profile; HiDPI absolute input -and physical edge crossing in relative mode still need their own checks. - -## Mac lid transitions - -Opening the MacBook lid reset the captured Dell from 1920×1080 to its native -6144×2560 mode while the stream remained connected. The built-in panel became -the main display at 1728×1117. The Mac adapter now reports lid state and the -capture display's current numeric ID. The controller checks every five seconds -and reconnects after a detected lid transition, restoring the selected profile -with a fresh mouse scale. Capture selection still uses the persistent display -UUID; the built-in panel's mode is not changed. - -Five additional regression tests cover lid recovery across controller restart, -preserving the original journal and assignment, numeric display-ID changes, -manual changes, compare-before-write conflicts and disconnect during recovery. -All 42 stream tests and the Windows adapter, scene and quality suites pass. -The explicit `reconnect --repair-display` path restored the live Dell to -1920×1080 with the lid open, leaving the built-in panel at 1728×1117 and retaining -the existing restoration baseline. A fresh physical close/open cycle is pending. - - -## Follow the Mac's main screen - -Added and installed on 2026-09-05 after the user confirmed that capturing the -Dell's extended desktop was the wrong behavior with the MacBook lid open. -`display.follow_main` resolves the current primary screen through CoreGraphics. -Only the configured external UUID receives the profile's display mode; other -primary screens retain their existing mode. Restoration is bound to the physical -UUID, including when the external display is temporarily unavailable. - -Six additional regression tests cover separate display baselines, switching in -both directions, unplugged-display restoration, ProMotion without an advertised -external mode, controller restart/disconnect during a main-screen change, -recovery after a capture-output write crash, and rejecting a changed main screen -before a write. All 48 stream tests pass; Windows adapter, scene and quality -suites also pass. - -The old live session disconnected with its journal fully restored. All MacBook -profiles now enable main-screen following. With the lid open, the new session -selected built-in display 1 at its unchanged 1728×1117 HiDPI/ProMotion setting, -with Sunshine output 1 and a 2560×1440/60 HEVC stream. A physical lid-close -event was then detected automatically: the stream reconnected to the Dell at -1920×1080 without HiDPI in the same quad tile in 20.9 seconds. Reopening the -lid automatically reconnected to built-in display 1 at its unchanged -1728×1117 HiDPI/ProMotion mode, with no error. Both physical transitions are -verified; subjective pointer behavior still needs user confirmation. - - -## Basic desktop profiles and native macOS adapter - -Installed on 2026-09-05. The local MacBook and work-laptop configurations now -contain only `desktop`, with host audio, ordinary absolute pointer input and -keep-awake enabled. The example configuration also starts with one desktop per -computer. Remote applications retain their own microphone/webcam selection; -this change does not forward or select those devices. - -The MacBook uses the new `macos` adapter. CoreGraphics reads the main display, -logical and actual pixel dimensions, and nominal refresh. Display modes are -never changed. Only capture output is restored; existing BetterDisplay profiles -remain supported for users who explicitly configure them. Two new tests cover -native configuration without a UUID/mode and rejection of mode writes without -invoking BetterDisplay. All 50 stream, 8 Windows, 14 quality and 24 scene tests pass. - -A live native probe returned built-in display 1, 1728×1117 logical / 3456×2234 -pixels, with a nominal 120 Hz timing (the physical ProMotion setting was not -changed). The desktop connected with a 2560×1440/60 decoded stream and host audio -requested. No active local audio stream was available to verify playback muting; -actual speakers, microphone and webcam behavior remains a user/app check. The -previously verified main-screen lifecycle is shared by this adapter; a fresh -physical lid cycle with the native adapter has not yet been exercised. - -The native Mac test disconnected and cleared its output journal. Windows SSH/helper -was unreachable during this change, so its updated profile was not live-tested; -its existing pending display recovery was preserved. diff --git a/docs/STREAMS.md b/docs/STREAMS.md deleted file mode 100644 index 6a756e0..0000000 --- a/docs/STREAMS.md +++ /dev/null @@ -1,301 +0,0 @@ -# Remote desktops - -Hypertile manages paired Sunshine desktops as Moonlight windows in named zones. -The user-session controller owns launching, placement, retries and host display -restoration. The layout engine only consumes workspace-specific reservations. - -Install with `./install.sh`, or update a development installation with -`./dev apply`. Python 3, Moonlight Qt, and the current Hyprland Lua API are -required. The loader starts one controller per user; commands also start it on -demand. No root service or persistent remote agent is installed. - -## Configure computers - -Copy [computers.example.json](computers.example.json) to -`~/.config/hypertile/computers.json` and replace the example identities. XDG config, -data, runtime, and state directories are supported. Configuration is version 1. -Pair each host in Moonlight first. Obtain its UUID from Moonlight's `hosts` -section in `~/.config/Moonlight Game Streaming Project/Moonlight.conf`; leave -certificates and keys there. No credentials belong in `computers.json`. - -`host` is the preferred address used to check reachability. Launches select the -paired UUID: Moonlight owns certificate verification, discovery and media-path -selection, using its saved addresses. Hypertile does not claim the media traveled -over Tailscale simply because a Tailscale hostname was configured. - -Set `title` to the exact final title, such as `MacBook - Moonlight`. The controller -requires that title, Moonlight's class, its owned process and the compositor's -window identity. It does not adopt a manually launched stream. Close that view -before the first managed connection. - -The basic example has one `desktop` profile per computer with `audio: host`. -Sound plays through the remote computer's selected speakers/headset/dock, with -local Moonlight playback muted. Conferencing apps running on that computer use -its own selected microphone and webcam; Hypertile does not select those devices -or forward the Linux computer's microphone/webcam. - -On macOS, `display: {"adapter": "macos"}` uses native CoreGraphics APIs to read -the main screen and its logical/pixel dimensions. It preserves macOS's resolution, -HiDPI and refresh settings, follows main-screen changes, and refreshes Sunshine's -capture/input context during reconnects. No BetterDisplay installation, display -UUID or forced mode is required. Sunshine still handles video capture/encoding -and Moonlight receives it. Only Sunshine's capture-output setting is journaled -and restored. The external screen may therefore use its normal ultrawide mode -with the lid closed; this basic profile does not force a 16:9 desktop. - -Sunshine must already have screen recording and input permissions. -Configure an approved SSH account with existing host-key trust and -noninteractive authentication. An optional absolute `ssh.control_path` uses an -already authenticated multiplex connection; once it expires the adapter reports -SSH unavailable. SSH passwords and Sunshine admin credentials are not stored. - -The optional `betterdisplay` adapter additionally manages a physical display's -mode. For that adapter, BetterDisplay must be running with CLI integration enabled. -Find its physical display UUID on the Mac: - -```bash -/Applications/BetterDisplay.app/Contents/MacOS/BetterDisplay \ - get -type=Display -name='Your display' -identifiers -``` - -Preflight authenticates an app-list request through Moonlight. The BetterDisplay adapter -also checks Sunshine's stored computer UUID over the approved SSH connection -before any display operation. It resolves the display UUID to the current -CoreGraphics display ID and checks that it is active. It verifies the advertised -mode and, when `require_ac` is true, -AC power. `output_name` in `~/.config/sunshine/sunshine.conf` is mapped to that ID. -Changing the display mode or capture output restarts Sunshine. Mode changes are -read back before restarting: Sunshine's macOS input context caches its pointer -scale at startup, so changing HiDPI afterward can leave mouse coordinates scaled -for the previous mode. -Display groups and virtual displays are excluded. -The BetterDisplay `connected` getter is not required: physical activity is read -through CoreGraphics. Permission status is reported as unknown until tested in -the stream. No lid/sleep settings are modified. - -Set `display.follow_main: true` on a BetterDisplay profile to capture the Mac's -current main screen. Opening the lid can then switch the stream from the external -display to the built-in panel; closing it switches back when macOS makes the -external display primary. The configured `display.uuid` and `display.mode` -apply only when that physical display is primary. Other screens retain their -own resolution, HiDPI and refresh settings, including ProMotion. This follows -macOS's main-screen selection; it does not change which screen is primary or -move apps between extended desktops. - -Main-screen changes trigger a reconnect in the same zone and refresh Sunshine's -input mapping. The previous display's mode is restored by its own UUID. If that -display is unplugged or changed independently, its restoration remains pending; -the built-in panel can still stream. Reconnect the missing display and use -`stream restore COMPUTER` after disconnect to retry pending restoration. -Disconnect an existing stream before enabling this policy in `computers.json`. - -The three sizes are separate. `display.mode.resolution` is the logical desktop; -`hidpi: true` renders twice as many pixels per dimension. `stream_resolution` is -the encoded video size. Probe and status expose the resolved host mode. For -example, a 1920×1080 HiDPI desktop renders at 3840×2160 and streams at 2560×1440. - -Sunshine 2026.516.143833 on the tested Mac produced an absolute-pointer offset -when HiDPI changed after Sunshine started. The adapter now restarts Sunshine -after changing modes or following the main screen to refresh that mapping. -`input: absolute` is the default; `input: relative` captures the pointer instead. -Use Ctrl+Alt+Shift+Z to release relative capture. - -Use `display.adapter: external` for any host whose display settings -are managed elsewhere. Hypertile reports this explicitly and changes no host -settings. The `betterdisplay` Mac adapter and the `windows` console helper provide -managed preparation and restoration. See [Windows display recovery](WINDOWS-DISPLAY-RECOVERY.md) -for installation, offline recovery, and dock/lid behavior. - -## Use a named zone - -```bash -hypertile-ctl computers --json -hypertile-ctl stream probe macbook --profile desktop --json -hypertile-ctl stream connect macbook --profile desktop --zone right --workspace 1 -hypertile-ctl stream status macbook --json -hypertile-ctl stream focus macbook -hypertile-ctl stream disconnect macbook -``` - -Use an existing workspace with a Hypertile layout and an existing, non-spacer -zone name. Omitting `--workspace` uses the active workspace. One fill/cycle zone -must remain available for local windows; the first release rejects assignments -that reserve every fill zone. Reserved zones remain empty while a source is -offline, even with an `empty = "collapse"` layout. Ordinary local overflow, -application rules and pins cannot fill them. Explicit disconnect releases them. - -`connect` returns an operation ID and desired state immediately after validation. -It accepts work, which may subsequently fail; inspect `status` for the result. -Repeated connects with the same profile and assignment reuse the operation or -focus its ready window. Use the [Scenes tab or scene commands](SCENES.md) to move an assignment -or `stream profile COMPUTER --profile NAME` to switch profiles with managed -teardown. A different assignment/profile passed directly to `connect` still -requires disconnect first. Conflicting computers or zone owners are rejected. - -**Super+Shift+Arrow** swaps a ready stream with the neighboring window, including -another stream. This updates the reserved zone and saved source assignment while -keeping the same connection, profile and host restoration journal. A local window -exchanged with a stream takes the vacated zone and can be swapped again normally. -Swaps currently require one window in each participating zone; a stacked target, -changed layout, or closed window produces a notification without restarting the -stream. The same shortcut continues to handle ordinary local windows. - -Launch rules keep startup windows on the requested workspace without initial -focus or fullscreen activation. Placement targets the final window and never -focuses it; `focus` and repeating a completed `connect` are explicit focus actions. -The stream continues across workspace switches. Small zone changes resize the -local view and do not change the host mode. - -`window-ready` means the final window was found and placed. The stock Moonlight -6.1 logs expose negotiation and decoder initialization, but do not prove frame -presentation or remote input. Status therefore says `video_ready: unverified`; -check the visible desktop and keyboard/mouse before relying on it. Raw Moonlight -logs are discarded. Only typed observations (video size, decoder initialization, -termination code, quit) are retained, without request URLs or pairing material. - -Moonlight shortcuts include **Ctrl+Alt+Shift+Z** to release mouse capture, -**Ctrl+Alt+Shift+Q** to disconnect, and **Ctrl+Alt+Shift+S** for statistics. System -key capture defaults to `never` so Omarchy shortcuts remain local. A profile can -set `system_keys: always` to forward Command/Windows keys while captured, or -`fullscreen` to enable this only in fullscreen. Toggle capture to use local -shortcuts again. Enter the stream with the pointer or use Toggle capture to -activate capture; focus alone may not activate a newly opened client. A Mac desktop profile with `never` cannot send Command-key -shortcuts through stock Moonlight. Set the computer’s `platform` to `macos`, -`windows`, or `linux` to report platform limits even with external display -management; BetterDisplay profiles also identify a Mac. All launches use -`--no-quit-after`, so disconnecting leaves the host applications running. - -## Profiles and lifetime - -- `audio: focus` mutes the client when its window loses focus (the default). -- `audio: continuous` keeps client audio playing in the background. -- `audio: host` retains playback on the host and mutes only the owned Moonlight - process’s local PulseAudio/PipeWire-Pulse sink inputs using `pactl`. Missing - `pactl` or unrecognized outputs are reported by `audio_health`; absent audio - streams remain waiting-for-audio. New outputs are checked every five seconds, - so brief startup playback can precede muting. Audible output and microphone - routing still require a real call check. -- `keep_awake: visible` sets a targeted Hyprland idle inhibitor while a ready - source is on a visible workspace. Omarchy's idle service respects compositor - inhibitors. `always` also uses Moonlight's stream-wide inhibition; `never` - requests neither. These settings never change the remote host's sleep policy. -- Video defaults are HEVC, 60 FPS, 60,000 Kbps, hardware decode, SDR and standard - chroma. HDR, AV1, 4:4:4 and higher rates require host/client validation. The - default aspect policy is fit; stretching is not supported. - -A `meeting-headset` profile can copy the desktop settings with `audio: host` -and `keep_awake: always`. Use `audio: continuous` for a `meeting-audio` profile -when listening locally. Keep the camera and microphone attached to the host; -these profiles do not forward local devices or establish meeting readiness. - -The Scenes tab's zone controls expose statistics, capture toggle and explicit -clipboard typing. Mac clipboard typing is unavailable with the tested stock Sunshine; -see [scene input controls](SCENES.md) and [live validation](STREAMS-VALIDATION.md). - -## Performance and reconnecting - -Use `stream reconnect COMPUTER` to restart a ready local view while retaining -its zone, profile and host restoration journal. `stream measure COMPUTER ---seconds 30` collects the completed decoder summary by reconnecting after the -requested interval. The Scenes tab's Performance panel (under the zone's More -controls) and `stream quality COMPUTER --json` show the results. `stream local COMPUTER` returns focus to a -local window on the same workspace. See [quality and interaction](STREAM-QUALITY.md) -for measurement scope, cancellation and capability limits. - -## Recovery - -Before host writes, Hypertile durably records every original/intended value in -`~/.local/state/hypertile/streams/state.json`. Display mode is a compound setting -(resolution, HiDPI, refresh), restored together. Nominal refresh timings can differ -by less than 0.15 Hz; actual timings are recorded separately from the request. -The adapter compares the current value with the expected value immediately before -writing. Remote operations share a host-side lock, including probes, so a timed-out -write cannot race with a restoration read. A partial preparation -restores changes that happened and retains any unresolved journal. Reconnects -reuse the first baseline. The journal is separate from session snapshots. - -Explicit disconnect, recognized normal window close and failed preparation -restore changed settings. If the host is unreachable, the local zone is released -and status reports `restore-pending`. If a value differs from the applied value, -it is preserved as a conflict. Retry restoration or explicitly accept the current -host settings: - -```bash -hypertile-ctl stream restore macbook -# Only after disconnect; deliberately forget the outstanding host journal: -hypertile-ctl stream release macbook --keep-host-settings -``` - -`restore` never reconnects. `release` requires the explicit flag because it gives -up automatic restoration. A new connection is refused while a journal remains. - -Reachability failures retry at most three times (2, 5 and 15 seconds), retaining -the zone and original journal. Moonlight's explicit no-video-traffic termination -can retry; unknown exits, pairing/configuration failures, decoder failures and -ambiguous closes stop for attention. No scheduled retry survives a disconnect. -Use `hypertile-ctl stream retry macbook` for a source still assigned to its zone. -Individual SSH steps have a 40-second deadline; the single writer accepts the -next command between steps, so a stalled remote operation can delay a command. -Running Mac sources recheck display identity, capture output, lid state and power -every five seconds. A detected lid transition reconnects the local view and -reapplies the selected display mode if macOS reset it. The original restoration -journal and tile assignment are retained. Recovery compares the observed mode -and lid state again before writing, so a later manual change is preserved. -Only the selected capture display is changed; the built-in panel keeps its mode. -With `follow_main`, a main-screen change also reconnects, and the profile mode -is applied only to the configured physical UUID when it is primary. Changes to -an unmanaged primary panel's mode trigger an input refresh without reverting -that mode. - -A managed mode change without a detected lid transition is preserved and -reported as degraded. To explicitly restore the selected profile in a running Mac stream: - -```sh -hypertile-ctl stream reconnect macbook --repair-display -``` - -Losing the SSH observation channel marks the source degraded while -its view keeps running; a confirmed missing display or power prerequisite stops -the view and requests restoration. - -Controller restart reconciles durable process ownership and in-progress journals. -A launch token, per-job lock and an intent check before exec prevent duplicate -launches after dispatch uncertainty. Intent changes and PID publication share a -lock, closing the gap in which a disconnect could miss a pending launch. -The PID survives exec from the launcher to -Moonlight. `hypertile-stream stop` stops only the controller for updates; views, -intent and journals survive. Start it again with `hypertile-stream daemon`. - -Session checkpoints store remote references/profile/assignment independently of -mapped windows and omit managed windows from generic application launching and -matching. Optional remote restoration never blocks local recovery. Existing -controller intent wins over a snapshot, including explicit disconnects. A source -whose zone or workspace layout changed requires attention; it is never silently -assigned elsewhere. The controller supports one active compositor per user. -On compositor restart it waits up to 45 seconds for local recovery to recreate a -workspace. An unavailable restored source stays in durable state even when its -computer definition is missing. Configure it and use `retry`, or disconnect it. - -CLI exit status is 0 for successful queries or accepted operations, 1 for failed -requests/prerequisites, and 2 for argument syntax errors. Accepted operations can -later fail; inspect their operation ID and observed state. Uninstall refuses to -remove the recovery code while sources or host journals remain active. - -## Validation - -```bash -python3 test/stream.py -lua test/stream.lua -python3 test/session.py -lua test/session.lua -lua test/harness.lua -``` - -The failure tests cover queued cancellation, duplicate requests, controller and -compositor-instance changes, wrong window identity, bounded retries, normal and -ambiguous exits, missing zones, partial writes, crash recovery between write and -readback, nominal refresh rounding, conflict preservation, restoration pending, -private log extraction, and session ownership. Live checks and host-specific -limitations are recorded in [the implementation report](STREAMS-VALIDATION.md); -sleep/wake, lid transitions, network disruption, -remote input, and audio require real host checks in addition to these tests. diff --git a/docs/WINDOWS-DISPLAY-RECOVERY.md b/docs/WINDOWS-DISPLAY-RECOVERY.md deleted file mode 100644 index bd3510e..0000000 --- a/docs/WINDOWS-DISPLAY-RECOVERY.md +++ /dev/null @@ -1,102 +0,0 @@ -# Windows display recovery - -The `windows` adapter owns the work laptop's display topology. Sunshine captures -the configured virtual display and encodes video; its `dd_configuration_option` -is disabled so two components cannot race to restore different monitor layouts. -Existing resolution/refresh settings on the virtual display are retained. This -adapter currently manages topology, not arbitrary mode/HDR/scaling changes. - -Before connecting, Hypertile records an ownership token locally. A helper in the -Windows console session saves the physical display configuration before making -the capture display the only active output. Reconnects reuse that baseline. -The helper reads back the active display identity before Moonlight launches. - -Disconnect and recognized normal window close send a cancellation to the helper. -Restoration keeps any currently active physical screens, removes the owned virtual -screen, or restores the saved physical configuration if its devices are available. -If docking changes invalidate that configuration, it selects an available physical -screen, preferring the built-in panel. With no physical screen available, it keeps -recovery pending and retries every two seconds, including when the lid opens. -It never deliberately applies an empty display configuration. - -The helper runs on the laptop independently of SSH. It observes Sunshine's -connection events and waits for all observed clients to leave before restoring. -A connection has 45 seconds to start; an unexpected disconnection has a 15-second -grace period for reconnects. An explicit disconnect requests earlier recovery. -Unknown connection state blocks automatic recovery. An active stream is never -ended solely because an SSH status check fails. The connection-event parser -requires Sunshine's info logging; changing the capture output or re-enabling its -display automation is reported as an ownership conflict. - -Only a fresh readback with at least one physical display active and the owned -virtual display inactive clears the Linux recovery journal. While disconnected, -Hypertile polls pending recovery without reconnecting the source. A physical -monitor activated during streaming is reported as a display conflict rather than -silently moving applications to an unseen desktop. - -## Setup - -Use an already paired Sunshine host and an approved, strict-host-key SSH alias. -The account must be able to install the helper and must have an active console -session. Start with a working physical desktop and no active stream. - -```sh -python3 stream/windows_display.py --ssh work-laptop \ - --pairing-uuid PAIRED-SUNSHINE-UUID \ - --output-uuid SUNSHINE-OUTPUT-UUID \ - --capture-hardware MTT1337 -``` - -Setup verifies the Sunshine pairing/capture identities, runs 18 recovery-policy, -connection-event and atomic-journal checks, runs a read-only native display probe -in the console session, and backs up -`sunshine.conf` before disabling Sunshine's display automation. A failed setup -restores that setting if nobody has changed it in the meantime. No SSH, firewall, -VPN, power, or execution-policy settings are changed. - -The helper lives in `C:\ProgramData\Hypertile\display`. The scheduled task -**Hypertile Display Recovery** runs as the configured interactive user without -elevation, starts at logon, and also runs on battery. Private requests accept only -`probe`, `status`, `prepare`, and `restore`, with identity checks, expiration, -monotonic sequence numbers and cancellation tombstones. They cannot specify code -or arbitrary commands. `state.json` retains the original snapshot and recovery -state; `status.json` is the current readback. Setup refuses to overwrite an -existing installation; upgrades must first reconcile its journal. - -Use the returned `device_id` in each managed profile: - -```json -{ - "platform": "windows", - "ssh": {"alias": "work-laptop"}, - "profiles": { - "desktop": { - "display": {"adapter": "windows", "device_id": "EXACT-ID-RETURNED-BY-SETUP"} - } - } -} -``` - -This fragment supplements the existing computer/profile settings. It does not -replace the pairing, title or stream-quality configuration. - -## Recovery and removal - -`hypertile-ctl stream restore work-laptop` retries an outstanding recovery. The -helper can finish an already-armed recovery without Linux or SSH. It preserves -active physical displays and prevents its virtual display from being left as the -only output after a completed stream. - -To remove the Windows helper, first disconnect and verify a working physical -screen and an idle helper. Stop and unregister **Hypertile Display Recovery**, -then restore the backed-up Sunshine display option if it is still the setting -installed by this helper. Preserve other later Sunshine configuration changes. -Retain the recovery journal until the physical display is confirmed working. -Uninstalling Hypertile on Linux intentionally does not disable remote recovery. - -Hardware validation must cover normal disconnect, window close, docking changes, -lid close/open and recovery without SSH. Native display API success confirms -Windows' output configuration; the user must confirm the panel actually lights. -Pre-login, lock-screen, sleep/wake and reboot behavior require separate validation. -The adapter assumes one dedicated virtual capture display; additional unmanaged -virtual displays require separate support. diff --git a/docs/computers.example.json b/docs/computers.example.json deleted file mode 100644 index 3e31448..0000000 --- a/docs/computers.example.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "version": 1, - "computers": { - "macbook": { - "host": "macbook.example.ts.net", - "pairing_uuid": "00000000-0000-0000-0000-000000000001", - "title": "MacBook - Moonlight", - "ssh": { - "user": "yourname" - }, - "profiles": { - "desktop": { - "stream_resolution": "2560x1440", - "fps": 60, - "bitrate": 60000, - "codec": "HEVC", - "decoder": "hardware", - "hdr": false, - "yuv444": false, - "aspect": "fit", - "audio": "host", - "input": "absolute", - "keep_awake": "always", - "display": { - "adapter": "macos" - } - } - }, - "platform": "macos" - }, - "work-laptop": { - "host": "work-laptop.example.ts.net", - "pairing_uuid": "00000000-0000-0000-0000-000000000003", - "title": "WorkLaptop - Moonlight", - "profiles": { - "desktop": { - "stream_resolution": "2560x1600", - "fps": 60, - "bitrate": 60000, - "codec": "HEVC", - "audio": "host", - "keep_awake": "always", - "display": { - "adapter": "external" - } - } - }, - "platform": "windows" - } - } -} diff --git a/hypertile-session.lua b/hypertile-session.lua index d58fb61..2b617d0 100644 --- a/hypertile-session.lua +++ b/hypertile-session.lua @@ -6,9 +6,7 @@ local prefix = modname:match("^(.-)hypertile%-session$") or "" local engine = require(prefix .. "hypertile") local json = require(prefix .. "hypertile-json") local M = {} -local streams = {} local scene_content = {} -local last_local = {} local function selector(ws) if ws.special then return ws.name end @@ -18,13 +16,7 @@ end function M.snapshot() local out = { windows = json.array(), workspaces = json.array(), layouts = {}, monitors = json.array() } - local focused = hl.get_active_window() - if focused and focused.workspace and focused.class ~= "com.moonlight_stream.Moonlight" then - last_local[selector(focused.workspace)] = { address = focused.address, pid = focused.pid, stable_id = focused.stable_id } - end - out.streams = json.array() out.scene_content = scene_content - for _, source in pairs(streams) do out.streams[#out.streams + 1] = source end for _, mon in ipairs(hl.get_monitors()) do out.monitors[#out.monitors + 1] = { name = mon.name, x = mon.x, y = mon.y } end @@ -53,10 +45,6 @@ function M.snapshot() if win.mapped and win.workspace then local name = win.workspace.tiled_layout:match("^lua:(.+)$") local live = name and engine.live[name] - local source - 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 @@ -74,7 +62,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, scene_app = scene_app, + scene_app = scene_app, } end end @@ -106,42 +94,6 @@ local function dispatch(fn, args) if type(result) == "table" and result.error then error(result.error) end end -local function stream_target(request) - for _, ws in ipairs(hl.get_workspaces()) do - if selector(ws) == request.workspace then - if ws.tiled_layout ~= request.layout then error("assignment-invalid: workspace layout changed") end - local live = engine.live[request.layout:match("^lua:(.+)$")] - if live and request.zone_id then - request.zone = live.compiled.zone_ids[request.zone_id] - end - if not live or not live.compiled.leaf_set[request.zone] - or live.compiled.leaf_opts[request.zone].spacer then error("assignment-invalid: zone is missing or a spacer") end - return ws, live - end - end - error("assignment-invalid: workspace is unavailable") -end - -function M.stream_check(request) - local ws, live = stream_target(request) - local zones = {} - for name in pairs((live.state.scene_empty or {})[tostring(ws.id)] or {}) do - if name == request.zone then error("assignment-invalid: zone is intentionally empty") end - zones[name] = true - end - for _, s in pairs(streams) do - if s.computer ~= request.computer and s.workspace == request.workspace then - if s.zone == request.zone then error("zone already owned by " .. s.computer) end - zones[s.zone] = true - end - end - zones[request.zone] = true - local available = false - for _, name in ipairs(live.compiled.cycle) do if not zones[name] then available = true end end - if not available then error("assignment-invalid: leave one fill zone for local windows") end - return { workspace_id = ws.id, zone = request.zone, zone_id = live.compiled.leaf_opts[request.zone].id } -end - local function refresh_workspace(ws) -- Target a window explicitly: neither this nor session.place changes focus. for _, w in ipairs(hl.get_windows()) do @@ -205,7 +157,7 @@ function M.scene_content_apply(request) assert(zone and live.compiled.leaf_set[zone], "scene zone no longer exists") source.zone = zone if source.type == "empty" then empty[zone], blocked[zone] = true, true end - if source.type == "stream" then blocked[zone] = true end + assert(source.type == "local" or source.type == "app" or source.type == "empty", "unsupported scene source type") end local available = false for _, zone in ipairs(live.compiled.cycle) do if not blocked[zone] then available = true end end @@ -266,10 +218,6 @@ function M.scene_app_place(request) 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 @@ -300,41 +248,6 @@ function M.scene_restore_pins(request) return true end -function M.stream_shortcut(request) - local keys = { clipboard = "V", ["input-release"] = "Z", stats = "S" } - local key = assert(keys[request.action], "unknown stream shortcut") - local source = assert(streams[request.computer], "stream is not assigned") - for _, w in ipairs(hl.get_windows()) do - if w.address == source.address and w.stable_id == source.stable_id and w.pid == source.pid then - local address, stable_id, pid = w.address, w.stable_id, w.pid - local function still_owned() - for _, current in ipairs(hl.get_windows()) do - if current.address == address and current.stable_id == stable_id and current.pid == pid then return true end - end - end - local function send() - if not still_owned() then return end - dispatch(hl.dsp.send_key_state, { mods = "CTRL ALT SHIFT", key = key, state = "down", window = "address:" .. address }) - hl.timer(function() - if still_owned() then dispatch(hl.dsp.send_key_state, { mods = "CTRL ALT SHIFT", key = key, state = "up", window = "address:" .. address }) end - end, { timeout = 50, type = "oneshot" }) - end - -- Wayland clipboard offers and Moonlight keyboard capture require focus. - -- These controls are explicit actions; scene placement never takes focus. - dispatch(hl.dsp.focus, { window = "address:" .. address }) - hl.timer(send, { timeout = 80, type = "oneshot" }) - return true - end - end - error("stream has no ready window") -end - -local function source_for(win) - for _, s in pairs(streams) do - if s.address == win.address and s.pid == win.pid and s.stable_id == win.stable_id then return s end - end -end - local function swap_windows(request) local found = {} for i, ref in ipairs(request.windows) do @@ -354,8 +267,8 @@ local function swap_windows(request) end -- Resolve actual engine assignments, including rules, pins and reservations. --- This runs before the controller journals the request; it changes nothing. -function M.stream_swap_plan(request) +-- Planning validates both windows without changing either pin. +function M.swap_plan(request) local windows, ws, live = swap_windows(request) local by_address, targets = {}, {} for _, w in ipairs(hl.get_windows()) do @@ -366,7 +279,6 @@ function M.stream_swap_plan(request) end assert(next(by_address) == nil, "swap unavailable: waiting for layout order") local reserved = {} - for name, owner in pairs((live.state.reservations or {})[tostring(ws.id)] or {}) do reserved[name] = owner end for name in pairs((live.state.scene_empty or {})[tostring(ws.id)] or {}) do reserved[name] = true end local buckets = engine.assign(live.compiled, targets, { pins = live.state.pins, exclusive_pins = live.state.exclusive_pins, @@ -384,9 +296,8 @@ function M.stream_swap_plan(request) end end assert(zone, "swap unavailable: window has no zone") - local s = source_for(w) plan.windows[i] = { address = w.address, stable_id = w.stable_id, pid = w.pid, - computer = s and s.computer, before = zone, before_id = live.compiled.leaf_opts[zone].id, + before = zone, before_id = live.compiled.leaf_opts[zone].id, pin = live.state.pins[w.address], exclusive = live.state.exclusive_pins and live.state.exclusive_pins[w.address] or nil } end @@ -399,11 +310,10 @@ function M.stream_swap_plan(request) end -- Absolute assignments make retry after a lost IPC reply safe. Validate the --- entire exchange before changing either reservation; never focus or relaunch. -function M.stream_swap_apply(plan) +-- entire exchange before changing either pin; never focus or relaunch. +function M.swap_apply(plan) local windows, ws, live = swap_windows(plan) assert(selector(ws) == plan.workspace and ws.tiled_layout == plan.layout, "swap unavailable: layout changed") - local owners = {} for i, w in ipairs(windows) do local ref = plan.windows[i] assert(w.pid == ref.pid, "swap unavailable: window identity changed") @@ -412,235 +322,38 @@ function M.stream_swap_apply(plan) assert((not ref.zone_id or live.compiled.leaf_opts[ref.zone].id == ref.zone_id) and (not ref.before_id or (live.compiled.leaf_opts[ref.before] or {}).id == ref.before_id), "swap unavailable: zone identity changed") - local s = source_for(w) - assert((s and s.computer) == ref.computer, "swap unavailable: source ownership changed") - if s then - assert(s.workspace == plan.workspace and s.layout == plan.layout and (s.zone == ref.before or s.zone == ref.zone), - "swap unavailable: source assignment changed") - owners[s.computer] = true - else - local pin = live.state.pins[w.address] - assert(pin == ref.pin or pin == ref.zone, "swap unavailable: local pin changed") - end - end - for _, s in pairs(streams) do - if s.workspace == plan.workspace and not owners[s.computer] then - for _, ref in ipairs(plan.windows) do assert(s.zone ~= ref.zone, "swap unavailable: zone already owned") end - end + local pin = live.state.pins[w.address] + assert(pin == ref.pin or pin == ref.zone, "swap unavailable: pin changed") end local zones = {} for name in pairs((live.state.scene_empty or {})[tostring(ws.id)] or {}) do zones[name] = true for _, ref in ipairs(plan.windows) do assert(ref.zone ~= name, "swap unavailable: zone is intentionally empty") end end - for _, s in pairs(streams) do - if s.workspace == plan.workspace and not owners[s.computer] then zones[s.zone] = true end - end - for _, ref in ipairs(plan.windows) do if ref.computer then zones[ref.zone] = true end end local available = false for _, zone in ipairs(live.compiled.cycle) do if not zones[zone] then available = true end end assert(available, "swap unavailable: leave one fill zone for local windows") live.state.exclusive_pins = live.state.exclusive_pins or {} - local slots = (live.state.reservations or {})[tostring(ws.id)] or {} - for _, ref in ipairs(plan.windows) do if ref.computer then slots[streams[ref.computer].zone] = nil end end for _, ref in ipairs(plan.windows) do live.state.pins[ref.address] = ref.zone - live.state.exclusive_pins[ref.address] = not ref.computer or nil - if ref.computer then - streams[ref.computer].zone = ref.zone - streams[ref.computer].zone_id = ref.zone_id - slots[ref.zone] = ref.address - end + live.state.exclusive_pins[ref.address] = true end refresh_workspace(ws) return true end --- A target may disappear between planning and applying. Undo only values --- still owned by this exchange, including an apply whose reply was lost. -function M.stream_swap_cancel(plan) - local live = engine.live[plan.layout:match("^lua:(.+)$")] - if not live then return true end - local windows = {} - for _, w in ipairs(hl.get_windows()) do windows[w.address] = w end - local restored = {} - for _, ref in ipairs(plan.windows) do - local s = ref.computer and streams[ref.computer] - if s and s.address == ref.address and s.stable_id == ref.stable_id and s.pid == ref.pid - and s.workspace == plan.workspace and s.layout == plan.layout and (s.zone == ref.zone or s.zone == ref.before) then - local slots = live.state.reservations[tostring(s.workspace_id)] - slots[s.zone] = nil - restored[#restored + 1] = { source = s, ref = ref, slots = slots } - elseif not ref.computer then - local w = windows[ref.address] - if w and w.stable_id == ref.stable_id and w.pid == ref.pid and live.state.pins[ref.address] == ref.zone then - live.state.pins[ref.address] = ref.pin - if live.state.exclusive_pins then live.state.exclusive_pins[ref.address] = ref.exclusive end - end - end - end - for _, item in ipairs(restored) do - item.source.zone = item.ref.before - item.source.zone_id = item.ref.before_id - item.slots[item.ref.before] = item.ref.address - live.state.pins[item.ref.address] = item.ref.before - end - for _, ws in ipairs(hl.get_workspaces()) do if selector(ws) == plan.workspace then refresh_workspace(ws) end end - return true -end - function M.swap(active, target) local live = engine.live[active.workspace.tiled_layout:match("^lua:(.+)$")] - local managed = source_for(active) or source_for(target) - if not managed and not live.state.pins[active.address] and not live.state.pins[target.address] then return false end - if managed then - -- The external controller persists intent. Do not wait for its IPC from - -- the compositor thread: it queries us while handling this command. + if not live.state.pins[active.address] and not live.state.pins[target.address] then return false end + local request = { windows = { active, target } } + local ok, err = pcall(function() M.swap_apply(M.swap_plan(request)) end) + if not ok then local function quote(v) return "'" .. tostring(v):gsub("'", "'\\''") .. "'" end - hl.exec_cmd("hypertile-stream swap " .. quote(active.address) .. " " .. quote(active.stable_id) - .. " " .. quote(target.address) .. " " .. quote(target.stable_id)) - else - local request = { windows = { active, target } } - local ok, err = pcall(function() M.stream_swap_apply(M.stream_swap_plan(request)) end) - if not ok then - local function quote(v) return "'" .. tostring(v):gsub("'", "'\\''") .. "'" end - hl.exec_cmd("notify-send 'Hypertile swap' " .. quote(err)) - end + hl.exec_cmd("notify-send 'Hypertile swap' " .. quote(err)) end return true end -function M.stream_release(request) - local old = streams[request.computer] - if not old then return true end - streams[request.computer] = nil - if hl.window_rule then - hl.window_rule({ name = "hypertile-stream-" .. request.computer, enabled = false }) - end - for _, live in pairs(engine.live) do - local reservations = live.state.reservations or {} - local slots = reservations[tostring(old.workspace_id)] - if slots then slots[old.zone] = nil end - if old.address then live.state.pins[old.address] = nil end - end - for _, ws in ipairs(hl.get_workspaces()) do - if ws.id == old.workspace_id then refresh_workspace(ws) end - end - return true -end - -function M.stream_assign(request) - local checked = M.stream_check(request) - local ws, live = stream_target(request) - local found - if request.address then - for _, w in ipairs(hl.get_windows()) do - if w.address == request.address and w.pid == request.pid and w.stable_id == request.stable_id - and w.class == "com.moonlight_stream.Moonlight" and w.title == request.title then found = w end - end - if not found then error("stream window identity changed") end - end - M.stream_release(request) - request.workspace_id = checked.workspace_id - streams[request.computer] = request - if hl.window_rule and request.title then - -- Launch rules can be consumed by Moonlight's temporary renderer window. - -- Cover the subsequent host-titled window while this source is assigned. - local title = request.title:gsub("([^%w _%-])", "\\%1") - hl.window_rule({ name = "hypertile-stream-" .. request.computer, enabled = true, - match = { class = "^com\\.moonlight_stream\\.Moonlight$", title = "^" .. title .. "$" }, - workspace = request.workspace .. " silent", no_initial_focus = true, - suppress_event = "fullscreen maximize activate activatefocus fullscreenoutput" }) - end - live.state.reservations = live.state.reservations or {} - local key = tostring(ws.id) - live.state.reservations[key] = live.state.reservations[key] or {} - live.state.reservations[key][request.zone] = request.address or true - if found then - -- Clear startup fullscreen once. Reconciliation must not undo a later - -- explicit compositor fullscreen action on the already placed window. - if not request.placed or selector(found.workspace) ~= request.workspace or found.floating then - M.place({ address = found.address, layout = request.layout, - saved = { workspace = request.workspace, pin = request.zone, floating = false } }) - end - live.state.pins[found.address] = request.zone - end - refresh_workspace(ws) - return true -end - -function M.stream_focus(request) - local s = streams[request.computer] - if not s or not s.address then error("stream has no ready window") end - for _, w in ipairs(hl.get_windows()) do - if w.address == s.address and w.pid == s.pid and w.stable_id == s.stable_id then - dispatch(hl.dsp.focus, { window = "address:" .. w.address }) - return true - end - end - error("stream window has closed") -end - -function M.stream_close(request) - local s = assert(streams[request.computer], "stream is not assigned") - for _, w in ipairs(hl.get_windows()) do - if w.address == s.address and w.pid == s.pid and w.stable_id == s.stable_id then - dispatch(hl.dsp.window.close, { window = "address:" .. w.address }) - return true - end - end - return false -- Already closed; the controller still checks its owned process. -end - -function M.stream_local(request) - local s = assert(streams[request.computer], "stream is not assigned") - local active = hl.get_active_window() - if not active or active.address ~= s.address or active.pid ~= s.pid or active.stable_id ~= s.stable_id then - return { released = false, reason = "The selected stream is not focused" } - end - local previous = last_local[s.workspace] - local target - for _, w in ipairs(hl.get_windows()) do - if w.mapped and not w.hidden and w.workspace and selector(w.workspace) == s.workspace - and w.class ~= "com.moonlight_stream.Moonlight" then - target = target or w - if previous and w.address == previous.address and w.pid == previous.pid and w.stable_id == previous.stable_id then - target = w - break - end - end - end - if not target then return { released = false, reason = "Open a local window or use Toggle capture" } end - hl.dispatch(hl.dsp.release_input_capture()) - dispatch(hl.dsp.focus, { window = "address:" .. target.address }) - return { released = true, focused_local = true } -end - -function M.stream_launch(request) - M.stream_check(request) - -- exec preserves the PID through the small launcher and into Moonlight, - -- so Hyprland's launch rules apply to this process only. - local result = hl.dispatch(hl.dsp.exec_cmd(request.command, { - workspace = request.workspace .. " silent", no_initial_focus = true, - suppress_event = "fullscreen maximize activate activatefocus fullscreenoutput", - })) - if type(result) == "table" and result.error then error(result.error) end - return true -end - -function M.stream_inhibit(request) - local s = streams[request.computer] - if not s or not s.address then return false end - for _, w in ipairs(hl.get_windows()) do - if w.address == s.address and w.pid == s.pid and w.stable_id == s.stable_id then - dispatch(hl.dsp.window.set_prop, { window = "address:" .. w.address, prop = "idle_inhibit", - value = request.enabled and "always" or "none" }) - return true - end - end - return false -end - -- A layout that still exists keeps its current definition: the user may have -- edited it since the snapshot, and silently reverting to the saved spec -- until the next reload would be surprising. Only a layout that no longer diff --git a/hypertile.lua b/hypertile.lua index cace122..461434b 100644 --- a/hypertile.lua +++ b/hypertile.lua @@ -355,7 +355,7 @@ function M.assign(compiled, targets, state) if not reserved[name] then fallback = name; break end end end - assert(fallback, "stream assignments must leave a local overflow zone") + assert(fallback, "empty zones must leave a window overflow zone") take(fallback, i) end end @@ -493,7 +493,6 @@ function M.recalculate(compiled, ctx, state) local win = targets[1].window local workspace = win and win.workspace and tostring(win.workspace.id) local reserved = {} - for name, owner in pairs(state and state.reservations and state.reservations[workspace] or {}) do reserved[name] = owner end for name in pairs(state and state.scene_empty and state.scene_empty[workspace] or {}) do reserved[name] = true end state = setmetatable({ reserved = reserved }, { __index = state or {} }) local buckets = M.assign(compiled, targets, state) diff --git a/install.sh b/install.sh index 4f5892d..a434bb4 100755 --- a/install.sh +++ b/install.sh @@ -63,11 +63,18 @@ if [[ -d "$plugin_dst/.git" && "$(cd "$plugin_dst" && pwd -P)" != "$src" ]]; the exit 1 fi -for tool in lua jq python3; do +for tool in lua jq python3 flock; do command -v "$tool" >/dev/null 2>&1 || { echo "install.sh: $tool is required" >&2; exit 1; } done [[ -e "$hypr/hyprland.lua" ]] || { echo "install.sh: $hypr/hyprland.lua not found; is this an Omarchy 4 (Lua config) system?" >&2; exit 1; } +PYTHONPATH="$src/session" python3 - "$state" <<'PY_PREFLIGHT' +from pathlib import Path +from upgrade import check_legacy +import sys +check_legacy(Path(sys.argv[1])) +PY_PREFLIGHT + # 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' @@ -94,6 +101,18 @@ for name in ("hypertile-scenes", "hypertile-session"): subprocess.run([str(entry), "stop"], env=env, stdout=subprocess.DEVNULL, check=True, timeout=10) PY_SERVICES +# Hold the migration lock through all runtime edits. Shared Remote Desktops +# guards coexist; a legacy writer cannot start while its files are retired. +mkdir -p "$state/streams" +exec 9>"$state/streams/writer.lock" +flock -sn 9 || { echo "install.sh: legacy controller is still running" >&2; exit 1; } +PYTHONPATH="$src/session" python3 - "$state" <<'PY_CHECK' +from pathlib import Path +from upgrade import check_legacy +import sys +check_legacy(Path(sys.argv[1])) +PY_CHECK + mkdir -p "$hypr/layouts" "$bin" "$state" # One backup per edited config file, overwritten on each edit. @@ -106,16 +125,20 @@ for f in hypertile.lua hypertile-json.lua hypertile-bridge.lua hypertile-layouts 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 -stream_data="${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/stream" -mkdir -p "$stream_data" -for f in "$src"/stream/*.py; do install -m 0644 "$f" "$stream_data/$(basename "$f")"; done -mkdir -p "$stream_data/windows" -for f in "$src"/stream/windows/*.ps1 "$src"/stream/windows/*.cs; do install -m 0644 "$f" "$stream_data/windows/$(basename "$f")"; done +scene_data="${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/scenes" +mkdir -p "$scene_data" +for f in "$src"/scenes/*.py; do install -m 0644 "$f" "$scene_data/$(basename "$f")"; done + +PYTHONPATH="$src/session" python3 - "$bin" "${XDG_DATA_HOME:-$HOME/.local/share}" <<'PY_CLEANUP' +from pathlib import Path +from upgrade import cleanup +import sys +cleanup(Path(sys.argv[1]), Path(sys.argv[2])) +PY_CLEANUP for f in "$src"/layouts/*.lua; do name="$(basename "$f")" diff --git a/plugin/Content.js b/plugin/Content.js index ccbe083..5ab6260 100644 --- a/plugin/Content.js +++ b/plugin/Content.js @@ -1,15 +1,13 @@ // Presentation helpers shared by the overlay and its tests. No side effects. -// One short phrase for a stream's observed state or a scene's phase. The +// One short phrase for an app's state or a scene's phase. The // empty string means there is nothing to say (no scene, no state). var STATUS = { - "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…", - disconnected: "Disconnected", restoring: "Restoring display…", "restore-pending": "Display restoration pending", - "needs-attention": "Needs attention", degraded: "Connection needs attention", pending: "Pending", - idle: "Not connected", "waiting-workspace": "Waiting for the workspace", "restore-builtin": "Restoring…" + "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: "Clearing previous placement…", + layout: "Applying layout…", connecting: "Placing apps…", "needs-attention": "Needs attention", + pending: "Pending", "waiting-workspace": "Waiting for the workspace", "restore-builtin": "Restoring…" } function status(value) { @@ -19,90 +17,35 @@ function status(value) { // States that want the user's attention (drawn in the urgent color). function troubled(value) { - return ["partial", "needs-attention", "restore-pending", "degraded", "disconnected"].indexOf(value) !== -1 + return ["partial", "needs-attention"].indexOf(value) !== -1 } -// States in which a stream is on its way to a window. -function inProgress(value) { - return ["connecting", "preflight", "preparing", "preparing-display", "startup-window", "reconnecting", "restoring", "stopping", "layout", "pending"].indexOf(value) !== -1 -} - -function streamControls(runtime) { - var r = runtime || {}, desired = r.desired === true - var journal = !!r.journal && Object.keys(r.journal).length > 0 - var pending = inProgress(r.observed) - var connected = desired && !!r.window - return { - focus: connected, - disconnect: desired, - reconnect: connected && ["window-ready", "degraded"].indexOf(r.observed) !== -1 ? "reconnect" - : !desired && !r.pid && !r.window && !journal && !pending ? "connect" : "", - retry: desired && !r.window && !pending, - restore: !desired && journal && !pending - } -} - -function audio(value) { - return value === "continuous" ? "Audio continues when you use local apps" : - value === "host" ? "Use the host headset; local stream playback is muted" : - "Audio plays while this desktop is focused" -} - -function audioShort(value) { - return value === "continuous" ? "audio continues" : value === "host" ? "host audio" : "audio while focused" -} - -// What sets a profile apart from the defaults, for the picker rows. -function traits(profile) { - var out = [] - if (!profile) return "" - if (profile.audio === "continuous") out.push("audio continues") - else if (profile.audio === "host") out.push("host audio") - if (profile.input === "relative") out.push("captured pointer") - if (profile.system_keys === "always") out.push("system keys") - return out.join(" · ") -} - -// The content assigned to `zone` on `workspace`: a live stream first, then -// the scene's record. Null means the zone holds local windows by fill order. +// The content assigned to a zone from the active scene. Null means the zone holds local windows by fill order. function source(catalog, workspace, zone, active) { if (!active || !catalog) return null - var streams = catalog.streams || [] - for (var i = 0; i < streams.length; i++) { - var r = streams[i] - if (r.desired && r.assignment.workspace === workspace && r.assignment.zone === zone) - return { type: "stream", computer: r.computer, profile: r.profile, status: r.observed, error: r.error, runtime: r } - } var sources = (catalog.current || {}).sources || [] if ((catalog.current || {}).phase === "restored") return null for (var j = 0; j < sources.length; j++) if (sources[j].zone === zone) { var item = sources[j] - if (item.type === "stream") { - for (var k = 0; k < streams.length; k++) if (streams[k].computer === item.computer) - return { type: item.type, computer: item.computer, profile: item.profile, status: item.status, error: item.error, runtime: streams[k] } - } return item } return null } // What the zone holds, as a name: "Local windows", "Empty", an app class, -// or "computer · profile". +// or an installed app name. 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 : "") + return "Unknown source" } -// The zone card's chip: the name, plus the state while a stream is not -// simply connected. +// The zone card's chip shows its assigned content. function chip(source) { if (!source) return "" - if (source.type !== "stream") return label(source) - var s = status(source.status) - return source.computer + (s !== "" && source.status !== "window-ready" ? " · " + s : "") + return label(source) } // The state of a zone's content in a few words, and whether it is a problem. @@ -127,10 +70,7 @@ function detail(source) { : 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)) - return bits.join(" · ") + return source.error || "Unknown source" } // The header for the workspace's scene: what it is called, what state it @@ -151,22 +91,3 @@ function sceneMeta(scene, layout, workspace) { if (s !== "") bits.push(s) return bits.join(" · ") } - -function performance(report) { - if (!report) return "Reconnect once to start collecting measurements." - var current = report.current || {}, lines = [] - if (typeof current.window_ready_ms === "number") - lines.push("Window ready in " + (current.window_ready_ms / 1000).toFixed(2) + " s · " + current.reason) - var last = report.last_measurement - if (last && last.metrics) { - var m = last.metrics - lines.push("Last decoder summary · " + last.profile) - if (typeof m.decode_ms === "number") lines.push("Decode " + m.decode_ms.toFixed(2) + " ms") - if (m.host_processing_ms) lines.push("Host processing " + m.host_processing_ms.average.toFixed(1) + " ms (includes more than encoding)") - if (typeof m.network_rtt_ms === "number") lines.push("Network RTT " + m.network_rtt_ms + " ms") - if (typeof m.rendered_fps === "number") lines.push("Rendered " + m.rendered_fps.toFixed(1) + " FPS") - if (typeof m.network_drop_pct === "number") lines.push("Network loss " + m.network_drop_pct.toFixed(2) + "% · jitter loss " + m.jitter_drop_pct.toFixed(2) + "%") - } else lines.push("No completed decoder measurements yet.") - lines.push("Readability: " + (report.readability || "unverified")) - return lines.concat(report.advice || []).join("\n") -} diff --git a/plugin/ContentPane.qml b/plugin/ContentPane.qml index a5f7e2a..b7fa4bb 100644 --- a/plugin/ContentPane.qml +++ b/plugin/ContentPane.qml @@ -8,7 +8,7 @@ import "Editor.js" as Editor // the workspace's layout holds, and the selected zone with the choices // for it. The header above it (the scene's name and state, Save and // Restore) is the rail's own. Every change goes through hypertile-ctl -// scene/stream; the catalog is re-read every couple of seconds while the +// scene; the catalog is re-read every couple of seconds while the // overlay is open, so the states here follow the controller. Column { id: pane @@ -16,7 +16,6 @@ Column { readonly property var catalog: overlay.contentCatalog || ({}) readonly property var scene: catalog.current || ({}) readonly property var scenes: catalog.scenes || [] - readonly property var computers: catalog.computers || [] readonly property bool ready: overlay.contentCatalog !== null && !overlay.catalogFailed readonly property bool usable: ready && overlay.viewedIsActive // The zones in fill order, as the numerals on the screen read them. @@ -32,11 +31,6 @@ Column { } readonly property var sel: overlay.selectedZone readonly property var source: overlay.contentFor(overlay.selected) - readonly property var runtime: (source && source.runtime) ? source.runtime : ({}) - readonly property var controls: Content.streamControls(runtime) - readonly property bool isStream: source !== null && source.type === "stream" - readonly property var quality: runtime.quality || ({}) - readonly property var measurement: (quality.current || {}).measurement || ({}) readonly property string appliedScene: (scene.document && scene.document.name && ["none", "restored"].indexOf(scene.phase) === -1) ? scene.document.name : "" readonly property color fg: overlay.foreground readonly property color accent: overlay.accent @@ -49,7 +43,7 @@ Column { // Another zone: back to the short view of it. Connections { target: pane.overlay - function onSelectedChanged() { pane.details = false; pane.overlay.contentMore = false; pane.overlay.performanceOpen = false } + function onSelectedChanged() { pane.details = false } } // ---------------------------------------------------------- pieces @@ -275,7 +269,7 @@ Column { Muted { visible: pane.overlay.catalogFailed - text: "Scenes need Hypertile's stream controller, which is not installed. Run install.sh from the plugin directory, then open the overlay again." + text: "Scenes need Hypertile's scene service, which is not installed. Run install.sh from the plugin directory, then open the overlay again." } Muted { visible: !pane.overlay.catalogFailed && pane.overlay.contentCatalog === null @@ -425,96 +419,7 @@ Column { width: pane.width spacing: Style.spacing.xxs Body { text: Content.label(pane.source); font.bold: true } - Muted { text: Content.detail(pane.source); urgent: Content.state(pane.source).urgent && !pane.isStream } - Muted { visible: pane.isStream && !!pane.source.error; text: pane.isStream ? (pane.source.error || "") : ""; urgent: true } - } - - // ---- a stream: what to do with it - Flow { - visible: pane.isStream - width: pane.width - spacing: Style.spacing.sm - Action { text: "Focus"; tooltipText: "Focus the remote desktop and close"; enabled: !pane.overlay.busy && pane.controls.focus; onClicked: pane.overlay.streamAction("focus", pane.source.computer, true) } - Action { text: "Disconnect"; visible: pane.controls.disconnect; tooltipText: "Close the view; the zone goes back to local windows"; onClicked: pane.overlay.streamAction("disconnect", pane.source.computer) } - Action { - text: "Reconnect" - tooltipText: "Open the remote desktop in this zone" - enabled: !pane.overlay.busy && pane.controls.reconnect !== "" - onClicked: { - if (pane.controls.reconnect === "connect") - pane.overlay.assignContent("stream", pane.source.computer, pane.source.profile) - else pane.overlay.streamAction("reconnect", pane.source.computer) - } - } - Action { text: "Retry"; visible: pane.controls.retry; onClicked: pane.overlay.streamAction("retry", pane.source.computer) } - Action { text: "Restore display"; visible: pane.controls.restore; tooltipText: "Put the host's display settings back"; onClicked: pane.overlay.streamAction("restore", pane.source.computer) } - } - - Disclosure { - visible: pane.isStream - text: "MORE CONTROLS" - open: pane.overlay.contentMore - onToggled: pane.overlay.contentMore = !pane.overlay.contentMore - } - - Column { - visible: pane.isStream && pane.overlay.contentMore - width: pane.width - spacing: Style.spacing.md - - Flow { - width: pane.width - spacing: Style.spacing.sm - Action { text: "Toggle capture"; tooltipText: "Ctrl+Alt+Shift+Z in the stream"; enabled: !pane.overlay.busy && !!pane.runtime.window; onClicked: pane.overlay.streamAction("input-release", pane.source.computer, true) } - Action { text: "Type clipboard"; tooltipText: "Type your local clipboard into the app focused on this computer"; enabled: !pane.overlay.busy && !!pane.runtime.window && (pane.runtime.clipboard || {}).state !== "unsupported"; onClicked: pane.overlay.streamAction("clipboard", pane.source.computer, true) } - Action { text: "Statistics"; tooltipText: "Moonlight's on-screen statistics"; enabled: !pane.overlay.busy && !!pane.runtime.window; onClicked: pane.overlay.streamAction("stats", pane.source.computer, true) } - Action { text: "Focus a local window"; tooltipText: "Leave the remote desktop for a local window on this workspace"; enabled: !pane.overlay.busy && !!pane.runtime.window; onClicked: pane.overlay.streamAction("local", pane.source.computer, true) } - } - Muted { - text: (pane.runtime.requested || {}).system_keys === "always" - ? "Command and Windows keys go to this computer while captured; toggle capture to use local shortcuts." - : "Command and Windows keys stay local; a profile with system keys sends them to this computer." - } - Muted { visible: !!(pane.runtime.clipboard || {}).reason; text: (pane.runtime.clipboard || {}).reason || "" } - - Flow { - width: pane.width - spacing: Style.spacing.sm - Action { text: "Performance"; selected: pane.overlay.performanceOpen; onClicked: pane.overlay.performanceOpen = !pane.overlay.performanceOpen } - Action { text: "Raw status"; selected: pane.details; onClicked: pane.details = !pane.details } - } - - Column { - visible: pane.overlay.performanceOpen - width: pane.width - spacing: Style.spacing.sm - Muted { text: Content.performance(pane.quality) } - Action { - text: pane.measurement.status === "recording" ? "Measuring; reconnects in 30 s" : "Measure in 30 s" - tooltipText: "Reconnects the view after 30 seconds to read Moonlight's decoder summary; host apps stay open" - enabled: !pane.overlay.busy && !!pane.runtime.window && !!(pane.quality.current || {}).quality_parser && pane.measurement.status !== "recording" - onClicked: pane.overlay.streamAction("measure", pane.source.computer) - } - Muted { visible: !!pane.quality.collection_reason; text: pane.quality.collection_reason || "" } - Label { text: "How does text look at this size?" } - Flow { - width: pane.width - spacing: Style.spacing.sm - Repeater { - model: [{ label: "Readable", value: "readable" }, { label: "Too small", value: "too-small" }, { label: "Blurry", value: "blurry" }] - Action { - required property var modelData - text: modelData.label - selected: pane.quality.readability === modelData.value - enabled: !pane.overlay.busy && !!pane.runtime.window - onClicked: pane.overlay.rateReadability(pane.source.computer, modelData.value) - } - } - } - Muted { text: "Timing measures when the window is ready, not its first frame; end-to-end latency is not available." } - } - - Muted { visible: pane.details; text: JSON.stringify(pane.runtime, null, 2) } + Muted { text: Content.detail(pane.source); urgent: Content.state(pane.source).urgent } } Muted { visible: pane.sel !== null && pane.sel.spacer === true; text: "A spacer never holds windows." } @@ -548,26 +453,6 @@ Column { } } - Repeater { - model: pane.computers - Column { - id: computer - required property var modelData - width: pane.width - spacing: Style.spacing.xxs - Label { text: computer.modelData.computer; topPadding: Style.spacing.xs; bottomPadding: Style.spacing.xxs } - Repeater { - model: computer.modelData.profiles - ListRow { - required property var modelData - text: modelData.name - trait: Content.traits(modelData) - current: pane.isStream && pane.source.computer === computer.modelData.computer && pane.source.profile === modelData.name - onClicked: pane.overlay.assignContent("stream", computer.modelData.computer, modelData.name) - } - } - } - } Column { width: pane.width spacing: Style.spacing.xxs @@ -600,7 +485,7 @@ Column { text: modelData trait: "one window" current: pane.source !== null && pane.source.type === "local" && pane.source.app_class === modelData - onClicked: pane.overlay.assignContent("local", "", "", modelData) + onClicked: pane.overlay.assignContent("local", modelData) } } } diff --git a/plugin/Overlay.qml b/plugin/Overlay.qml index 7761e47..b2fa2f2 100644 --- a/plugin/Overlay.qml +++ b/plugin/Overlay.qml @@ -49,10 +49,8 @@ Item { property var windows: [] // hypertile-ctl windows --json .windows property string defaultLayout: "" property bool contentMode: false // the rail's Scenes tab: zones are selected, not browsed - property bool performanceOpen: false - property bool contentMore: false // the selected stream's extra controls property var contentCatalog: null // hypertile-ctl scene catalog --json - property bool catalogFailed: false // no stream controller: scenes are unavailable, browsing is not + property bool catalogFailed: false // no scene service: scenes are unavailable, browsing is not property int catalogFailures: 0 property string catalogError: "" property bool namingScene: false @@ -60,8 +58,6 @@ Item { property bool switchConfirmed: false readonly property bool managedContent: { if (!contentCatalog) return false - var streams = contentCatalog.streams || [] - for (var i = 0; i < streams.length; i++) if (streams[i].desired && streams[i].assignment.workspace === workspaceId) return true return contentCatalog.current && ["none", "restored"].indexOf(contentCatalog.current.phase) === -1 } property int viewIndex: 0 @@ -155,7 +151,7 @@ Item { var seen = {}, out = [] for (var i = 0; i < windows.length; i++) { var w = windows[i] - if (String(w.workspace) === workspaceId && w.class && w.class !== "com.moonlight_stream.Moonlight" && !seen[w.class]) { + if (String(w.workspace) === workspaceId && w.class && !seen[w.class]) { seen[w.class] = true; out.push(w.class) } } @@ -196,7 +192,7 @@ Item { // // The Scenes tab: what each zone of the workspace's layout holds (local // windows, a remote desktop, one app, or nothing) and the saved scenes. - // Assignments and scenes are the stream controller's; the overlay asks + // Assignments and scenes are the scene service's; the overlay asks // through hypertile-ctl scene and shows the catalog it re-reads while open. function contentFor(zone) { return Content.source(contentCatalog, workspaceId, zone, viewedIsActive) } @@ -210,8 +206,6 @@ Item { pendingSwitch = null confirmingDelete = false choosingNew = false - contentMore = false - performanceOpen = false errorText = "" browseTimer.stop() revertBrowse() @@ -260,12 +254,10 @@ Item { function deleteScene(name) { sceneAction("remove", name) } - function assignContent(type, computer, profile, app) { + function assignContent(type, app) { if (!selected || !viewedIsActive) { errorText = "Select a zone in the current layout"; return } - var what = type === "empty" ? "Empty" : app ? app : computer ? computer + " · " + profile : "Local windows" + var what = type === "empty" ? "Empty" : app ? app : "Local windows" var args = ["scene", "content", "--workspace", workspaceId, "--zone", selected, "--type", type, "--json"] - if (computer) args.push("--computer", computer) - if (profile) args.push("--profile", profile) if (app) args.push("--app-class", app) runCtl(args, "Putting " + what + " in " + selected + "…", "") } @@ -278,22 +270,6 @@ Item { runCtl(args, "Opening " + app.name + " in " + selected + "…", "") } - // Focus-taking actions close the overlay (it holds the keyboard). - function streamAction(action, computer, closeOverlay) { - if (closeOverlay) { - Quickshell.execDetached([ctl, "stream", action, computer]) - dismiss() - return - } - var status = action === "disconnect" ? "Disconnecting " + computer + "…" - : action === "reconnect" ? "Reconnecting " + computer + "…" - : action === "retry" ? "Retrying " + computer + "…" - : action === "restore" ? "Restoring the display of " + computer + "…" - : action === "measure" ? "Measuring " + computer + " in 30 s…" - : "Updating " + computer + "…" - runCtl(["stream", action, computer, "--json"], status, "") - } - function selectContentNeighbor(dir) { if (!root.activeSpec) return var names = Editor.leafNames(root.activeSpec) @@ -303,10 +279,6 @@ Item { if (next !== "") root.selected = next } - function rateReadability(computer, value) { - runCtl(["stream", "readability", computer, value, "--json"], "Saving assessment…", "Readability recorded for this profile and view size") - } - // ------------------------------------------------------------ preferences FileView { @@ -599,15 +571,15 @@ Item { runCtl(["apply", root.viewed.name, "--workspace", workspace, "--quiet"], "Using " + root.viewed.name + " on workspace " + workspace + "…", "Workspace " + workspace + " uses " + root.viewed.name) } - // Whether a workspace has content assigned to zones (a scene, a stream). + // Whether a workspace has content assigned to zones (a scene). function contentWorkspace(workspace) { if (!root.contentCatalog) return false if ((root.contentCatalog.active_workspaces || []).indexOf(workspace) !== -1) return true - return (root.contentCatalog.streams || []).some(function(s) { return s.desired && s.assignment.workspace === workspace }) + return false } // Using another layout on a workspace with assigned content replaces the - // content (its streams disconnect), so it is asked about first. + // content (apps stay open), so it is asked about first. function askSwitch(workspaces, close) { root.applyQueue = [] root.confirmingDelete = false @@ -619,13 +591,8 @@ Item { var p = root.pendingSwitch if (!p) return "" var managed = p.workspaces.filter(function(w) { return contentWorkspace(w) }) - var names = [] - var streams = (root.contentCatalog && root.contentCatalog.streams) || [] - for (var i = 0; i < streams.length; i++) - if (streams[i].desired && managed.indexOf(streams[i].assignment.workspace) !== -1) names.push(streams[i].computer) var s = managed.length === 1 ? "Workspace " + managed[0] + " has content assigned to its zones. " : "Workspaces " + managed.join(", ") + " have content assigned to their zones. " - s += names.length > 0 ? names.join(", ") + (names.length === 1 ? " disconnects" : " disconnect") + " and every zone goes back to local windows; local apps stay open." - : "Every zone goes back to local windows; local apps stay open." + s += "Every zone goes back to local windows; apps stay open." return s + " Save the arrangement as a scene first to come back to it." } @@ -1145,9 +1112,9 @@ Item { } // The scene catalog: the workspace's scene, the saved scenes, the - // computers and the streams. Re-read every couple of seconds while open, + // installed apps. Re-read every couple of seconds while open, // so the states in the Scenes tab follow the controller. Without the - // stream controller (an older install) there are no scenes; the Scenes + // scene service (an older install) there are no scenes; the Scenes // tab says so, and nothing else is affected. Process { id: catalogProc @@ -1574,10 +1541,8 @@ Item { function peek(on: bool): void { root.peeking = on } function refresh(): void { root.refresh() } function content(on: bool): void { root.showContent(on) } - function more(on: bool): void { root.contentMore = on } - function performance(on: bool): void { root.showContent(true); root.contentMore = on; root.performanceOpen = on } - function assign(kind: string, computer: string, profile: string): void { root.assignContent(kind, computer, profile) } - function assignApp(cls: string): void { root.assignContent("local", "", "", cls) } + function assign(kind: string): void { root.assignContent(kind) } + function assignApp(cls: string): void { root.assignContent("local", cls) } function scene(action: string, name: string): void { root.sceneAction(action, name) } function saveScene(name: string): void { root.saveScene(name) } function saveSceneAs(): void { root.startSceneSave() } @@ -1631,7 +1596,7 @@ Item { undo: root.undoStack.length, status: root.statusText, error: root.errorText, workspaces: root.workspaces.length, windows: root.windows.length, defaultLayout: root.defaultLayout, committed: root.committedLayout, live: root.liveLayout, dockLeft: root.dockLeft, showKeys: root.showKeys, - area: root.area, contentMode: root.contentMode, performanceOpen: root.performanceOpen, contentMore: root.contentMore, + area: root.area, contentMode: root.contentMode, namingScene: root.namingScene, pendingSwitch: root.pendingSwitch, catalogFailed: root.catalogFailed, scene: root.contentCatalog ? root.contentCatalog.current : null }) } diff --git a/plugin/ZoneItem.qml b/plugin/ZoneItem.qml index 29657b5..bc7574e 100644 --- a/plugin/ZoneItem.qml +++ b/plugin/ZoneItem.qml @@ -189,7 +189,7 @@ Item { foreground: zone.fg fontFamily: zone.overlay.fontFamily fontSize: zone.overlay.uiFontSmall - strong: zone.source !== null && zone.source.type === "stream" + strong: zone.source !== null && zone.source.type === "app" anchors.verticalCenter: parent.verticalCenter } } diff --git a/stream/apps.py b/scenes/apps.py similarity index 99% rename from stream/apps.py rename to scenes/apps.py index e4795cc..fa411f2 100644 --- a/stream/apps.py +++ b/scenes/apps.py @@ -151,7 +151,7 @@ def step(self, record, snap): 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")] + found = [w for w in snap["windows"] if matches(w, source)] 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: diff --git a/stream/browse.py b/scenes/browse.py similarity index 86% rename from stream/browse.py rename to scenes/browse.py index 1f7f724..b8302db 100644 --- a/stream/browse.py +++ b/scenes/browse.py @@ -60,9 +60,6 @@ def command(self, request): scene = self.ctl.scenes.records.get(workspace, {}) if scene.get("phase", "ready") not in ("ready", "partial", "restored", "needs-attention"): raise ValueError("Wait for the scene to finish before browsing layouts") - sources = [r for r in self.ctl.records.values() if r["assignment"]["workspace"] == workspace] - if any(r["phase"] not in ("watching", "idle", "unresolved", "attention") for r in sources): - raise ValueError("Wait for the stream operation to finish before browsing layouts") base = {"layout": ws["layout"]} spec = snap.get("layouts", {}).get(ws["layout"].removeprefix("lua:"), {}).get("spec") if spec: @@ -92,8 +89,6 @@ def heartbeat(self, workspace, token): def tick(self): for workspace, record in list(self.active.items()): ended = record.get("ending") or record["epoch"] != self.epoch or self.clock() >= record["deadline"] - sources = [r for r in self.ctl.records.values() if r["assignment"]["workspace"] == workspace] - ended = ended or any(r["phase"] == "watching" and not self.ctl.processes.pid(r) for r in sources) if ended: self.end(workspace) @@ -105,7 +100,3 @@ def before_command(self, request): workspace = request.get("workspace") or self.ctl.compositor.snapshot()["workspace"] if str(workspace) in self.active: self.end(str(workspace)) - elif command not in ("status", "quality", "probe", "stop"): - # Explicit stream changes and swaps must reconcile the real layout. - for workspace in list(self.active): - self.end(workspace) diff --git a/stream/ipc.py b/scenes/ipc.py similarity index 97% rename from stream/ipc.py rename to scenes/ipc.py index 4332d9f..1dca92c 100644 --- a/stream/ipc.py +++ b/scenes/ipc.py @@ -1,4 +1,4 @@ -"""Private single-writer IPC shared by the independent scene and legacy stream services.""" +"""Private single-writer IPC for the scene service.""" import fcntl import json import os diff --git a/stream/scene_service.py b/scenes/scene_service.py similarity index 91% rename from stream/scene_service.py rename to scenes/scene_service.py index 903495b..032dfb8 100644 --- a/stream/scene_service.py +++ b/scenes/scene_service.py @@ -19,9 +19,8 @@ def __init__(self, root, config, compositor, now=time.time): 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.running = True + self.scenes = Manager(self) self.browser = Browser(self) if self.state.get("instance") != compositor.instance: self.state["app_launches"].clear() @@ -49,11 +48,6 @@ def command(self, payload): 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) diff --git a/stream/scenes.py b/scenes/scenes.py similarity index 68% rename from stream/scenes.py rename to scenes/scenes.py index 07c1b56..49c7d66 100644 --- a/stream/scenes.py +++ b/scenes/scenes.py @@ -91,8 +91,8 @@ def persist(self, workspace, rule): class Manager: - def __init__(self, controller, computers, layouts=None, directory=None): - self.ctl, self.computers = controller, computers + def __init__(self, controller, layouts=None, directory=None): + self.ctl = controller self.apps = AppPlacement(controller) self.layouts = layouts or Layouts() self.directory = directory or controller.config.parent / "scenes" @@ -120,8 +120,7 @@ def resolve(self, doc, migrate=False): check(len(ids) == len(set(ids)), "duplicate zone identities") inputs = doc.get("sources", {}) check(isinstance(inputs, dict), "scene sources must be an object") - computers = self.computers() - output, blocked, used, apps = {}, set(), set(), set() + output, blocked, apps = {}, set(), set() for key, source in inputs.items(): check(isinstance(source, dict), "invalid scene source") matches = [n for n in nodes if n.get("id") == key] @@ -130,7 +129,8 @@ 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", "app"), "monitor inputs require a validated hardware profile") + check(kind != "stream", "Legacy stream source: replace it with an installed app desktop ID") + check(kind in ("local", "empty", "app"), "unsupported scene source type") check(not leaf.get("spacer") or kind == "empty", "A spacer can only contain Empty") value = {"type": kind, "zone": leaf["name"]} if kind == "app": @@ -139,22 +139,13 @@ def resolve(self, doc, migrate=False): 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) - check(computer not in used, "A computer can occupy only one scene zone") - used.add(computer) - value.update(computer=computer, profile=profile) if kind == "local" and source.get("app_class"): 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(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"): + if kind == "empty": blocked.add(leaf["name"]) output[leaf.get("id", leaf["name"])] = value cycle = spec.get("cycle", spec.get("fill", [n["name"] for n in nodes if not n.get("spacer")])) @@ -181,12 +172,6 @@ def capture(self, workspace, snap, migrate=True): if active and active.get("phase") not in ("waiting-workspace", "restored") and active.get("document") and active["document"].get("layout_id") == spec.get("layout_id"): bindings = copy.deepcopy(active["document"]["sources"]) bindings = {k: v for k, v in bindings.items() if v["type"] != "stream"} - by_name = {n["name"]: n.get("id", n["name"]) for n in leaves(spec)} - for r in self.ctl.records.values(): - if r["desired"] and r["assignment"]["workspace"] == workspace: - zone = r["assignment"]["zone"] - check(zone in by_name, "An assigned source zone is missing: " + zone) - bindings[by_name[zone]] = {"zone": zone, "type": "stream", "computer": r["computer"], "profile": r["profile"]} doc = {"version": 1, "layout": entry["name"], "sources": bindings} if spec.get("layout_id"): doc["layout_id"] = spec["layout_id"] @@ -215,16 +200,10 @@ def public(self, record): out["sources"] = [] for key, source in record.get("document", {}).get("sources", {}).items(): item = {**source, "zone_id": key} - if source["type"] == "stream": - r = self.ctl.records.get(source["computer"]) - item["status"] = r["observed"] if r else "pending" - item["error"] = r.get("error") if r else None - item["suppressed"] = source["computer"] in record.get("suppressed", []) - else: - item["status"] = "ready" - for result in record.get("results", []): - if result["zone"] == source["zone"]: - item.update(result) + item["status"] = "ready" + 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")) @@ -237,29 +216,24 @@ def has_apps(self, document): 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(): - if source["type"] == "stream": - r = self.ctl.records.get(source["computer"]) - 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 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"): + 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"): 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), None) if not ws: - check(getattr(self.ctl, "generic_scenes", False) and self.has_apps(document), "Workspace is unavailable") + check(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} record = {"workspace": workspace, "document": document, "spec": spec, "baseline": baseline, "generation": (old or {}).get("generation", 0) + 1, "operation": uuid.uuid4().hex, - "phase": "stopping", "launched": [], "suppressed": [], "restoring": restoring, "modified": self.modified(document)} + "phase": "stopping", "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. @@ -273,55 +247,8 @@ def start(self, doc, workspace, snap, restoring=False, force=False): other.setdefault("apps", {})[key] = {"status": "moved"} self.records[workspace] = record self.ctl.persist() - self.stop_superseded(record) return self.public(record) - def desired(self, record): - return {s["computer"]: {**s, "zone_id": key} for key, s in record["document"]["sources"].items() - if s["type"] == "stream" and s["computer"] not in record.get("suppressed", [])} - - def stop_superseded(self, record): - wanted = self.desired(record) - for computer, r in self.ctl.records.items(): - if not r["desired"] or r["assignment"]["workspace"] != record["workspace"]: - continue - target = wanted.get(computer) - retain = target and r["profile"] == target["profile"] and r["phase"] == "watching" and r.get("window") - if not retain: - self.ctl.command({"command": "disconnect", "computer": computer, "scene_internal": True}) - - def interrupted(self, request): - if request.get("scene_internal") or request.get("command") not in ("connect", "disconnect", "restore", "release", "swap"): - return - computer = request.get("computer") - for record in self.records.values(): - if computer in self.desired(record): - if request["command"] in ("disconnect", "restore", "release"): - record.setdefault("suppressed", []).append(computer) - record["modified"] = True - - def swapped(self): - for record in self.records.values(): - if record["phase"] not in ("ready", "partial"): - continue - nodes = {n["name"]: n.get("id") for n in leaves(record["spec"])} - sources = record["document"]["sources"] - changes = [] - for key, source in list(sources.items()): - r = self.ctl.records.get(source.get("computer")) - if source["type"] == "stream" and r and r["assignment"]["zone"] != source["zone"]: - changes.append((key, nodes[r["assignment"]["zone"]], {**source, "zone": r["assignment"]["zone"]})) - for old, _, _ in changes: - sources.pop(old) - for old, key, source in changes: - displaced = sources.pop(key, None) - if displaced: - displaced["zone"] = next(n["name"] for n in leaves(record["spec"]) if n.get("id") == old) - sources[old] = displaced - sources[key] = source - if changes: - record["modified"] = True - def command(self, request): action = request.get("action", "current") if action in ("browse", "browse-end"): @@ -353,14 +280,8 @@ def command(self, request): return self.public(self.records.get(workspace)) if action == "catalog": self.ctl.browser.heartbeat(workspace, request.get("browse_token")) - computers = self.computers() return {"version": 1, "current": self.public(self.records.get(workspace)), "scenes": self.command({"action": "list"})["scenes"], - "computers": [{"computer": k, "profiles": [{"name": p, "audio": s.get("audio", "focus"), - "input": s.get("input", "absolute"), "keep_awake": s.get("keep_awake", "visible"), - "system_keys": s.get("system_keys", "never"), - "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} @@ -391,56 +312,29 @@ def command(self, request): baseline = active["baseline"] if baseline["document"]: return self.start(baseline["document"], workspace, snap, restoring=True) - # Built-in layout baselines have no source zones. - for r in self.ctl.records.values(): - if r["desired"] and r["assignment"]["workspace"] == workspace: - self.ctl.command({"command": "disconnect", "computer": r["computer"], "scene_internal": True}) active.update(phase="restore-builtin", restoring=True) self.ctl.persist() return self.public(active) 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) - for computer in self.desired(active): - r = self.ctl.records.get(computer) - if r and not r["desired"] and r.get("journal"): - self.ctl.command({"command": "restore", "computer": computer, "scene_internal": True}) - elif r and r["desired"] and not self.ctl.processes.pid(r): - self.ctl.command({"command": "retry", "computer": computer, "scene_internal": True}) - elif not r or not r["desired"]: - active["launched"] = [c for c in active["launched"] if c != computer] - active["content_applied"] = False - self.ctl.persist() - return self.public(active) + self.apps.retry(active) + return self.start(active["document"], workspace, snap, force=True) if action == "content": doc = self.capture(workspace, snap) spec = self.layouts.get(doc["layout"], doc["layout_id"])["spec"] 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", "app_title", "desktop_id"): + for k in ("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")} doc["sources"][leaf["id"]] = source return self.start(doc, workspace, snap) raise ValueError("unknown scene command") - def blocks(self, computer): - r = self.ctl.records[computer] - scene = self.records.get(r["assignment"]["workspace"]) - return bool(scene and scene["phase"] in ("stopping", "layout", "restore-builtin") and r["desired"]) - def restore_refs(self, refs): for ref in refs: workspace = str(ref.get("workspace", "")) @@ -448,7 +342,7 @@ def restore_refs(self, refs): continue self.records[workspace] = {"workspace": workspace, "document": copy.deepcopy(ref["document"]), "phase": "waiting-workspace", "generation": 0, "operation": uuid.uuid4().hex, - "launched": [], "suppressed": [], "deadline": self.ctl.now() + 45} + "deadline": self.ctl.now() + 45} self.ctl.persist() def tick(self): @@ -464,26 +358,14 @@ 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"]) 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"]] + if any(w["selector"] == workspace for w in snap["workspaces"]) or (self.has_apps(record["document"])): self.start(record["document"], workspace, snap) - restored = self.records[workspace] - # A scene in an older checkpoint must not undo a disconnect. - restored["suppressed"] = suppressed 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", "waiting-session"): return - wanted = self.desired(record) - local_records = [r for r in self.ctl.records.values() if r["assignment"]["workspace"] == workspace] if phase in ("stopping", "restore-builtin"): - # Old local views must exit before their zones/layout are reused. - if any(not r["desired"] and self.ctl.processes.pid(r) for r in local_records): - return - for r in local_records: - self.ctl.compositor.call("stream_release", {"computer": r["computer"]}) - self.ctl.applied.pop(r["computer"], None) self.ctl.compositor.call("scene_clear", {"workspace": workspace}) if phase == "restore-builtin": rule = self.ctl.compositor.call("scene_layout", {"workspace": workspace, "layout": record["baseline"]["layout"]}) @@ -493,18 +375,10 @@ def step(self, record): # Resolve again before writes: a queued scene cannot use stale IDs. document, spec = self.resolve(record["document"]) record.update(document=document, spec=spec, phase="layout") - wanted = self.desired(record) self.ctl.persist() if record["phase"] == "layout": rule = self.ctl.compositor.call("scene_layout", {"workspace": workspace, "layout": "lua:" + record["document"]["layout"], "spec": record["spec"]}) self.layouts.persist(workspace, rule) - for computer, target in wanted.items(): - r = self.ctl.records.get(computer) - if r and r["desired"]: - r["assignment"] = {"workspace": workspace, "layout": "lua:" + record["document"]["layout"], - "zone": target["zone"], "zone_id": target["zone_id"]} - self.ctl.applied.pop(computer, None) - record["launched"].append(computer) record.update(phase="connecting", content_applied=False) self.ctl.persist() snap = self.ctl.compositor.snapshot() @@ -513,7 +387,7 @@ def step(self, record): # 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"]): + if not record.get("content_applied") 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, @@ -531,7 +405,6 @@ def step(self, record): document, spec = self.resolve(record["document"]) if document != record["document"]: record.update(document=document, spec=spec, content_applied=False, phase="stopping") - self.stop_superseded(record) return # Reconcile the full reservation set before either new name is assigned. # A user selected a different layout directly: don't force this scene back. if live_ws["layout"] != "lua:" + record["document"]["layout"]: @@ -542,22 +415,11 @@ def step(self, record): 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(): - if computer in record["launched"]: - continue - r = self.ctl.records.get(computer) - if r and (r["desired"] or r["phase"] != "idle" or self.ctl.processes.pid(r) or r.get("journal")): - continue - 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 and not any(r["status"] == "waiting-window" for r in app_results): + problems = any(r.get("status") == "needs-attention" for r in record.get("results", [])) + if 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/session/streams.py b/session/scene_recovery.py similarity index 75% rename from session/streams.py rename to session/scene_recovery.py index 52c2b91..e2ea99b 100644 --- a/session/streams.py +++ b/session/scene_recovery.py @@ -1,4 +1,4 @@ -"""Session integration for independent scenes and preserved legacy stream recovery.""" +"""Session capture and recovery for generic app scenes.""" import copy import json import os @@ -8,25 +8,12 @@ def capture(desktop): 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: - state = {"version": 1, "computers": {}} - if state.get("version") != 1: - raise ValueError("unsupported stream state version; session capture paused") scene_path = root / "scenes/state.json" - scene_state = json.loads(scene_path.read_text()) if scene_path.exists() else state + scene_state = json.loads(scene_path.read_text()) if scene_path.exists() else {"version": 1} 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(): - if r.get("token"): - 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"): @@ -50,19 +37,9 @@ def capture(desktop): 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")) 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") - managed = managed or bool(tokens.intersection(env)) - except OSError: - pass - if not managed: - windows.append(w) + windows = [w for w in desktop["windows"] if w["address"] not in scene_windows] desktop["windows"] = windows - desktop["streams"] = sorted(sources, key=lambda s: s["computer"]) + desktop.pop("streams", None) desktop.pop("scene_content", None) # Compositor addresses are not scene definitions. desktop["scenes"] = scene_refs addresses = {w["address"] for w in windows} @@ -74,7 +51,7 @@ def capture(desktop): def restore(sources, scenes=()): - warnings = [] + warnings = ["Legacy remote assignments were not reopened. Migrate them to installed app sources."] if sources else [] 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())] @@ -82,7 +59,6 @@ def restore(sources, scenes=()): 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"): diff --git a/session/service.py b/session/service.py index f0f73af..c45d2da 100644 --- a/session/service.py +++ b/session/service.py @@ -19,7 +19,7 @@ import sys import tempfile import time -import streams +import scene_recovery def atomic_json(path, value): @@ -407,7 +407,7 @@ def __init__(self, record, compositor, launchers, now, persist, progress=None): self.deadline = now + max(30, len(self.desktop["windows"]) * 3 + 10) self.settled = None compositor.call("prepare", self.desktop) - self.warnings.extend(streams.restore(self.desktop.get("streams", []), self.desktop.get("scenes", []))) + self.warnings.extend(scene_recovery.restore(self.desktop.get("streams", []), self.desktop.get("scenes", []))) def progress(self): return {"matches": self.matches, "launched": sorted(self.launched)} @@ -532,7 +532,7 @@ def __init__(self, store, compositor, launchers): def record(self): return {"version": 1, "instance": self.compositor.instance, "saved_at": time.time(), - "desktop": self.launchers.capture(streams.capture(self.compositor.snapshot()))} + "desktop": self.launchers.capture(scene_recovery.capture(self.compositor.snapshot()))} def status(self): value = {"instance": self.compositor.instance, "mode": self.mode, "error": self.error} diff --git a/session/upgrade.py b/session/upgrade.py new file mode 100644 index 0000000..c110914 --- /dev/null +++ b/session/upgrade.py @@ -0,0 +1,31 @@ +"""Retire known legacy runtime files; never remove configuration or journals. + +Callers must hold the shared legacy writer lock before checking or deleting. +Remote Desktops holds the same shared lock; the old controller requires EX. +""" +import json +from pathlib import Path + +def check_legacy(state): + path = state / "streams/state.json" + if not path.exists(): + return + value = json.loads(path.read_text()) + if value.get("version") != 1 or not isinstance(value.get("computers"), dict): + raise ValueError("Unrecognized legacy state; preserve its recovery tools before upgrading") + pending = [name for name, record in value["computers"].items() if record.get("desired") or record.get("journal")] + if pending: + raise ValueError("Disconnect/restore legacy Hypertile connections before upgrading: " + ", ".join(pending)) + +def obsolete(bin_dir, data): + root = data / "hypertile" + paths = [bin_dir / "hypertile-stream", root / "session/streams.py"] + paths += [root / "stream" / (name + ".py") for name in + ("controller", "mac_display", "windows_display", "audio", "quality", "scenes", "scene_service", "apps", "browse", "ipc")] + paths += [root / "stream/windows" / name for name in + ("Guard.ps1", "Policy.ps1", "Display.cs", "Test.ps1", "Install.ps1")] + return paths + +def cleanup(bin_dir, data): + for path in obsolete(bin_dir, data): + path.unlink(missing_ok=True) diff --git a/stream/audio.py b/stream/audio.py deleted file mode 100644 index b312b2b..0000000 --- a/stream/audio.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Mute only this managed client's local playback for a host-headset profile.""" -import json -import shutil -import subprocess - - -def host_headset(pid, run=subprocess.run): - if not shutil.which("pactl"): - return {"state": "unverified", "error": "Install pactl or mute Moonlight locally for host-headset audio"} - try: - result = run(["pactl", "--format=json", "list", "sink-inputs"], capture_output=True, text=True, timeout=3, check=True) - inputs = json.loads(result.stdout) - clients = {} - if any(not v.get("properties", {}).get("application.process.id") and v.get("client") is not None for v in inputs): - # Native PipeWire/SDL nodes omit the PID that PulseAudio streams - # carry. pactl exposes their owning client using its serial index - # (not the PipeWire client.id property on the node). - result = run(["pactl", "--format=json", "list", "clients"], capture_output=True, text=True, timeout=3, check=True) - clients = {str(v["index"]): v.get("properties", {}).get("application.process.id") - for v in json.loads(result.stdout)} - streams = [v for v in inputs if str(v.get("properties", {}).get("application.process.id") - or clients.get(str(v.get("client")))) == str(pid)] - for stream in streams: - if not stream.get("mute"): - run(["pactl", "set-sink-input-mute", str(int(stream["index"])), "1"], capture_output=True, timeout=3, check=True) - return {"state": "local-muted" if streams else "waiting-for-audio", "playback": "unverified"} - except (OSError, ValueError, subprocess.SubprocessError) as error: - return {"state": "unverified", "error": "Could not mute local playback: " + type(error).__name__} diff --git a/stream/controller.py b/stream/controller.py deleted file mode 100644 index 017213b..0000000 --- a/stream/controller.py +++ /dev/null @@ -1,1120 +0,0 @@ -"""Single-writer, user-session controller for paired Moonlight desktops. - -The state file is the write-ahead log: intent precedes host writes and launches. -Each loop performs one bounded step, then accepts the next user command. No -worker can complete after a newer disconnect generation has been accepted. -""" -import argparse -import configparser -import copy -import fcntl -import json -import os -from pathlib import Path -import re -import shlex -import shutil -import signal -import socket -import subprocess -import sys -import time -import uuid - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "session")) -from service import Compositor, atomic_json, read_json -from mac_display import same_setting, manages_mode -from scenes import Manager -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" -TOKEN = "HYPERTILE_STREAM_TOKEN" -NAME = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}\Z") -UUID = re.compile(r"[a-fA-F0-9]{8}(?:-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}\Z") - - -def paths(): - home = Path.home() - state = Path(os.environ.get("XDG_STATE_HOME") or home / ".local/state") / "hypertile/streams" - runtime = Path(os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}") / "hypertile-stream" - config = Path(os.environ.get("XDG_CONFIG_HOME") or home / ".config") / "hypertile/computers.json" - return state, runtime, config - - -def load(path, default): - try: - return read_json(path) - except FileNotFoundError: - return copy.deepcopy(default) - - -def require(condition, message): - if not condition: - raise ValueError(message) - - -def resolution(value): - require(isinstance(value, str) and re.fullmatch(r"\d{3,5}x\d{3,5}", value), "resolution must be WIDTHxHEIGHT") - require(all(240 <= int(n) <= 16384 for n in value.split("x")), "resolution outside supported range") - return value - - -def configuration(path): - value = load(path, {"version": 1, "computers": {}}) - require(value.get("version") == 1 and isinstance(value.get("computers"), dict), "unsupported computers.json schema") - identities = set() - for name, computer in value["computers"].items(): - require(NAME.fullmatch(name), "invalid computer ID") - require(isinstance(computer.get("host"), str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.:-]{0,252}", computer["host"]), "invalid host") - require(UUID.fullmatch(computer.get("pairing_uuid", "")), "pairing_uuid must reference a paired Moonlight computer") - identity = computer["pairing_uuid"].lower() - require(identity not in identities, "two computers refer to the same pairing identity") - identities.add(identity) - require(isinstance(computer.get("title"), str) and 1 <= len(computer["title"]) <= 250, "title must be the final Moonlight window title") - require(isinstance(computer.get("profiles"), dict) and computer["profiles"], "computer needs profiles") - require(computer.get("platform", "unknown") in ("macos", "windows", "linux", "unknown"), "invalid host platform") - for profile_name, p in computer["profiles"].items(): - require(NAME.fullmatch(profile_name), "invalid profile ID") - resolution(p.get("stream_resolution")) - require(type(p.get("fps", 60)) is int and 20 <= p.get("fps", 60) <= 240, "invalid FPS") - require(type(p.get("bitrate", 60000)) is int and 1000 <= p.get("bitrate", 60000) <= 200000, "invalid bitrate") - require(p.get("codec", "HEVC") in ("HEVC", "H.264", "AV1", "auto"), "invalid codec") - require(p.get("audio", "focus") in ("focus", "continuous", "host"), "invalid audio policy") - require(p.get("input", "absolute") in ("absolute", "relative"), "invalid input policy") - require(p.get("system_keys", "never") in ("never", "fullscreen", "always"), "invalid system key capture policy") - require(p.get("keep_awake", "visible") in ("visible", "always", "never"), "invalid keep_awake policy") - require(p.get("aspect", "fit") == "fit", "only aspect=fit is supported") - require(p.get("decoder", "hardware") in ("hardware", "software", "auto"), "invalid decoder") - for flag in ("hdr", "yuv444"): - require(type(p.get(flag, False)) is bool, "invalid " + flag) - display = p.get("display", {"adapter": "external"}) - require(display.get("adapter") in ("external", "betterdisplay", "macos", "windows"), "unknown display adapter") - if display["adapter"] == "windows": - require(computer.get("platform") == "windows", "Windows display adapter requires platform=windows") - require(windows_display.ALIAS.fullmatch(computer.get("ssh", {}).get("alias", "")), "Windows adapter requires an approved ssh.alias") - device = display.get("device_id", "") - require(isinstance(device, str) and 1 <= len(device) <= 512 and device.startswith("\\\\?\\DISPLAY#") - and all(ord(c) >= 32 for c in device), "Windows adapter requires a persistent display device_id") - if display["adapter"] == "betterdisplay": - require(UUID.fullmatch(display.get("uuid", "")), "display requires a persistent UUID") - require(type(display.get("follow_main", False)) is bool, "follow_main must be boolean") - mode = display.get("mode", {}) - resolution(mode.get("resolution")) - require(type(mode.get("hidpi")) is bool and type(mode.get("refresh")) in (int, float) - and 20 <= mode["refresh"] <= 240, "invalid host mode") - if display["adapter"] in ("betterdisplay", "macos"): - if display["adapter"] == "macos": - require(computer.get("platform") == "macos", "native adapter requires platform=macos") - require(display.get("follow_main", True) is True, "native desktop follows the main display") - require("mode" not in display and "uuid" not in display, "native desktop preserves the main display mode") - ssh = computer.get("ssh", {}) - require(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]{0,63}", ssh.get("user", "")), "Mac adapter requires ssh.user") - if "control_path" in ssh: - require(isinstance(ssh["control_path"], str) and ssh["control_path"].startswith("/"), "SSH control_path must be absolute") - return value["computers"] - - -def moonlight_hosts(): - base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") - c = configparser.ConfigParser(interpolation=None) - c.read(base / "Moonlight Game Streaming Project/Moonlight.conf") - hosts = c["hosts"] if c.has_section("hosts") else {} - result = {} - for key, value in hosts.items(): - if key.endswith("\\uuid"): - prefix = key[:-4] - # Certificate material never leaves Moonlight's own configuration. - result[value.lower()] = {"name": hosts.get(prefix + "hostname"), - "paired": bool(hosts.get(prefix + "srvcert")), - "address": hosts.get(prefix + "manualaddress")} - return result - - -class Host: - def __init__(self, computer, profile): - self.computer, self.profile = computer, profile - self.display = profile.get("display", {"adapter": "external"}) - - def remote(self, operation, **values): - if self.display["adapter"] == "windows": - return windows_display.remote(self.computer, self.display, operation, **values) - ssh = self.computer["ssh"] - argv = ["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=5"] - if ssh.get("control_path"): - argv += ["-S", ssh["control_path"]] - argv += [ssh["user"] + "@" + self.computer["host"], "python3 -"] - request = {"operation": operation, "adapter": self.display["adapter"], "display_uuid": self.display.get("uuid"), - "follow_main": self.display.get("follow_main", self.display["adapter"] == "macos"), - "pairing_uuid": self.computer["pairing_uuid"], **values} - program = "REQUEST = " + repr(request) + "\n" + Path(__file__).with_name("mac_display.py").read_text() - p = subprocess.run(argv, input=program, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=40) - if p.returncode: - # SSH diagnostics can contain configuration details; expose a typed error. - raise ValueError("host-unreachable: SSH unavailable; check the approved account/control socket") - try: - result = json.loads(p.stdout) - except ValueError: - raise ValueError("display-probe-failed: remote adapter returned invalid data") from None - if not result.get("ok"): - raise ValueError(result.get("error", "display operation failed")) - return result["result"] - - def probe(self, pairing=True): - info = {"adapter": self.display["adapter"], "permissions": "unknown", "media_path": "unknown"} - if pairing: - known = moonlight_hosts().get(self.computer["pairing_uuid"].lower()) - require(known and known["paired"], "pairing-required: pair this UUID in Moonlight first") - require(shutil.which("moonlight"), "moonlight-missing: install Moonlight Qt") - version = subprocess.run(["moonlight", "--version"], text=True, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, timeout=4) - match = re.search(r"Moonlight v?(\d+\.\d+(?:\.\d+)?)", version.stdout) - info["client_version"] = match[1] if match else "unknown" - # Address is useful for diagnostics; the actual launch uses the paired - # UUID so host selection and certificate verification stay in Moonlight. - try: - with socket.create_connection((self.computer["host"], 47989), timeout=4): - pass - except OSError: - raise ValueError("host-unreachable: Sunshine port 47989 unavailable") from None - info["pairing"] = "configured; certificate checked by Moonlight at connection" - apps = subprocess.run(["moonlight", "list", self.computer["pairing_uuid"]], text=True, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=12) - require(apps.returncode == 0 and "Desktop" in (line.strip() for line in apps.stdout.splitlines()), - "pairing-or-app-required: Moonlight could not list the paired host's Desktop app") - info["pairing"] = "authenticated app list via Moonlight" - info["moonlight_saved_address"] = known["address"] - if self.display["adapter"] == "external": - return {**info, "restoration": "externally-managed", "display": "externally-managed"} - observed = self.remote("probe") - if self.display["adapter"] == "windows": - require(not observed.get("error"), observed.get("error", "Windows display helper error")) - return {**info, "display": observed, "restoration": "managed"} - require(not manages_mode(self.display, observed) or - any(same_setting("mode", self.display["mode"], m) for m in observed["modes"]), - "display-mode-unavailable: requested mode is not advertised") - require(not self.display.get("require_ac", False) or observed["ac_power"], "power-required: connect the Mac to AC") - return {**info, **observed, "restoration": "managed"} - - def change(self, field, expected, value, **guards): - return self.remote("change", field=field, expected=expected, value=value, **guards) - - def for_display(self, identity): - profile = copy.deepcopy(self.profile) - profile["display"].update(uuid=identity, follow_main=False) - return Host(self.computer, profile) - - -def prepare(record, host, persist): - if host.display["adapter"] == "external": - return - if host.display["adapter"] == "windows": - return windows_display.prepare(record, host, persist) - observed = host.probe(pairing=False) - following = host.display.get("follow_main", host.display["adapter"] == "macos") - desired = {"output": observed["identity"]["displayID"]} - if manages_mode(host.display, observed): - desired["mode"] = host.display["mode"] - recovery = record.get("display_recovery") - if recovery: - require(observed.get("topology") == recovery["topology"], "display-topology-changed: lid changed again during recovery") - for field in desired: - require(same_setting(field, observed["current"][field], recovery["current"][field]) - or same_setting(field, observed["current"][field], desired[field]), - "restore-conflict: display changed after recovery was requested") - journal = record.setdefault("journal", {}) - if "mode" not in desired and "mode" in journal: - # A mode belongs to its physical UUID, never to whichever panel is now - # primary. Restore the old display when reachable; retain pending work - # if it has been unplugged or changed independently. - restore(record, host, persist, fields=("mode",)) - observed = host.probe(pairing=False) - require(observed["identity"]["displayID"] == desired["output"], "display-topology-changed: main display moved") - # Capture every baseline before the first mutation. Restarting Sunshine - # must not turn a side effect into the next setting's "original" value. - for field in desired: - current = observed["current"][field] - if field not in journal and not same_setting(field, current, desired[field]): - journal[field] = {"original": current, "applied": desired[field], "phase": "intent"} - if following and field == "mode": - journal[field]["display_uuid"] = observed["identity"]["UUID"] - persist() - for field in desired: - current = observed["current"][field] - entry = journal.get(field) - if entry is None: - require(same_setting(field, current, desired[field]), "restore-conflict: unchanged setting moved during preparation") - continue - if field == "mode" and following: - require(entry.get("display_uuid", host.display["uuid"]).lower() == observed["identity"]["UUID"].lower(), - "display-identity-changed: mode journal belongs to another display") - if field == "output" and recovery and entry["applied"] != desired[field]: - require(entry["applied"] == recovery["current"][field], "capture-display-changed: output no longer owned") - # Follow a new main display or a renewed CoreGraphics ID without - # replacing the original Sunshine output baseline. - entry.update(applied=desired[field], phase="intent") - persist() - require(same_setting(field, entry["applied"], desired[field]), "display-identity-changed: restore the previous journal first") - if same_setting(field, current, entry["applied"]): - entry["phase"] = "applied" # Recover a crash after the write, before readback. - persist() - continue - expected = recovery["current"][field] if recovery else entry["original"] - require(same_setting(field, current, expected) and (recovery or entry["phase"] == "intent"), - "restore-conflict: host setting changed while owned") - guards = {"expected_identity": observed["identity"]["UUID"]} if following else {} - host.change(field, current, entry["applied"], **guards) - observed = host.probe(pairing=False) - require(observed["identity"]["displayID"] == desired["output"], "display-topology-changed: main display moved") - require(same_setting(field, observed["current"][field], entry["applied"]), "display-readback-failed: restoration required") - entry["requested"] = desired[field] - entry["applied"] = observed["current"][field] - entry["phase"] = "applied" - persist() - if following: - # Opening/closing a panel can change Sunshine's cached input display - # even when its numeric output setting needed no write. - host.remote("refresh", expected_identity=observed["identity"]["UUID"], expected=observed["current"]) - record["resolved"] = {**record.get("resolved", {}), **{k: v for k, v in observed.items() if k != "modes"}} - record["mac_topology"] = observed.get("topology") - record.pop("display_recovery", None) - persist() - - -def restore(record, host, persist, fields=("mode", "output")): - if host.display["adapter"] == "windows": - return windows_display.restore(record, host, persist) - journal = record.get("journal", {}) - if not journal: - return True - # Mode is a compound setting: changing only one component can select another - # mode. Compare/restore the entire tuple, then the capture output. - for field in fields: - entry = journal.get(field) - if not entry: - continue - target = host.for_display(entry.get("display_uuid", host.display["uuid"])) if field == "mode" and host.display.get("follow_main") else host - try: - observed = target.remote("probe") - except ValueError as error: - if field != "mode" or "display-missing" not in str(error): - raise - entry["phase"] = "unavailable" - persist() - continue - current = observed["current"][field] - if same_setting(field, current, entry["original"]): - del journal[field] - persist() - continue - recovery = record.get("display_recovery") - lid_reset = (recovery and observed.get("topology") == recovery["topology"] - and same_setting(field, current, recovery["current"][field])) - if not same_setting(field, current, entry["applied"]) and not lid_reset: - entry["phase"] = "conflict" - persist() - continue - target.change(field, current, entry["original"]) - observed = target.remote("probe") - require(same_setting(field, observed["current"][field], entry["original"]), "restore-readback-failed") - del journal[field] - persist() - if not journal: - record.pop("display_recovery", None) - return not journal - - -def stream_argv(computer, p): - return ["moonlight", "stream", "--resolution", p["stream_resolution"], "--fps", str(p.get("fps", 60)), - "--bitrate", str(p.get("bitrate", 60000)), "--display-mode", "windowed", - "--absolute-mouse" if p.get("input", "absolute") == "absolute" else "--no-absolute-mouse", - "--capture-system-keys", p.get("system_keys", "never"), "--no-quit-after", "--no-game-optimization", - "--video-codec", p.get("codec", "HEVC"), "--video-decoder", p.get("decoder", "hardware"), - "--keep-awake" if p.get("keep_awake") == "always" else "--no-keep-awake", - "--mute-on-focus-loss" if p.get("audio", "focus") == "focus" else "--no-mute-on-focus-loss", - "--audio-on-host" if p.get("audio") == "host" else "--no-audio-on-host", - "--hdr" if p.get("hdr") else "--no-hdr", "--yuv444" if p.get("yuv444") else "--no-yuv444", - computer["pairing_uuid"], "Desktop"] - - -def log_event(line): - """Moonlight Qt 6.1 evidence only; raw lines/URLs/keys are never retained.""" - if "://" in line: - return None - m = re.search(r"Video stream is (\d+)x(\d+)x(\d+)", line) - if m: - return {"negotiated_video": {"width": int(m[1]), "height": int(m[2]), "fps": int(m[3])}} - if "FFmpeg-based video decoder chosen" in line: - return {"decoder": "initialized", "video_ready": "unverified"} - m = re.search(r"Connection terminated: (-?\d+)", line) - if m: - return {"terminated": int(m[1])} - if "Quit event received" in line: - return {"quit": True} - if "No video received from host" in line: - return {"error": "no-video"} - if "not paired" in line.lower(): - return {"error": "pairing-required"} - return None - - -def owned(pid, token): - if not pid or not token: - return False - try: - proc = Path("/proc") / str(pid) - return (TOKEN + "=" + token).encode() in (proc / "environ").read_bytes().split(b"\0") and ") Z " not in (proc / "stat").read_text() - except OSError: - return False - - -def launch_job(path): - """Exec Moonlight in the same PID Hyprland assigned its launch rules to. - - A logger child consumes a pipe; only extracted, typed observations reach disk. - A per-job lock and the current generation close the dispatch/restart gap. - """ - job = read_json(path) - lock = open(path.with_suffix(".lock"), "w") - try: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - lock.close() - return - # This lock intentionally survives exec until the stream exits. - os.set_inheritable(lock.fileno(), True) - gate = (path.parent / (job["computer"] + ".gate")).open("a") - fcntl.flock(gate, fcntl.LOCK_EX) - # The gate is close-on-exec. A disconnect cannot slip between validating - # intent and publishing this PID, then miss a process that launches late. - records = read_json(Path(job["state"]))["computers"] - current = records.get(job["computer"], {}) - if not current.get("desired") or current.get("token") != job["token"]: - gate.close() - lock.close() - return - atomic_json(path.with_suffix(".pid"), {"pid": os.getpid()}) - read_fd, write_fd = os.pipe() - child = os.fork() - if child == 0: - gate.close() - lock.close() - os.close(write_fd) - supported = job.get("client_version", "").startswith("6.1.") - evidence = {"parser": "moonlight-qt-6.1" if supported else "unsupported-version", "video_ready": "unverified"} - stats = VideoStats() - if supported: - evidence["quality_parser"] = 1 - atomic_json(path.with_suffix(".events"), evidence) - with os.fdopen(read_fd, errors="replace") as pipe: - for line in pipe: - event = log_event(line) if supported else None - metrics = stats.feed(line) if supported else None - if metrics: - evidence.update(performance=metrics, performance_at=time.time()) - if event: - evidence.update(event) - if event or metrics: - atomic_json(path.with_suffix(".events"), evidence) - evidence["closed"] = True - atomic_json(path.with_suffix(".events"), evidence) - os._exit(0) - os.close(read_fd) - os.dup2(write_fd, 1) - os.dup2(write_fd, 2) - os.close(write_fd) - os.execvp(job["argv"][0], job["argv"]) - - -class Processes: - def __init__(self, root, compositor): - self.root, self.compositor = root, compositor - - def job(self, record): - return self.root / (record["token"] + ".json") - - def pid(self, record): - if not record.get("token"): - return None - pid = load(self.job(record).with_suffix(".pid"), {}).get("pid") - return pid if owned(pid, record["token"]) else None - - def events(self, record): - return load(self.job(record).with_suffix(".events"), {}) if record.get("token") else {} - - def launch(self, record): - path = self.job(record) - atomic_json(path, {"state": str(self.root / "state.json"), "token": record["token"], - "client_version": record.get("resolved", {}).get("client_version", "unknown"), - "computer": record["computer"], "argv": stream_argv(record["config"], record["settings"])}) - wrapper = Path(os.environ["HYPERTILE_SRC"]) / "bin/hypertile-stream" if os.environ.get("HYPERTILE_SRC") else Path.home() / ".local/bin/hypertile-stream" - cmd = "exec " + shlex.join(["env", TOKEN + "=" + record["token"], str(wrapper), "launch-job", str(path)]) - self.compositor.call("stream_launch", {**record["assignment"], "computer": record["computer"], "command": cmd}) - - def stop(self, record, force=False): - pid = self.pid(record) - if pid: - try: - os.kill(pid, signal.SIGKILL if force else signal.SIGTERM) - except ProcessLookupError: - pass - - -class Controller: - 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 - self.state = load(root / "state.json", {"version": 1, "computers": {}}) - require(self.state.get("version") == 1, "unsupported stream state version") - self.records = self.state["computers"] - self.running = True - 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(): - record.pop("pid", None) # Reconcile using the token and job's durable PID. - if record.get("desired") and record.get("instance") != compositor.instance: - if self.processes.pid(record): - self.processes.stop(record) - record["phase"] = "restart-stop" - record["observed"] = "reconnecting" - record["instance"] = compositor.instance - self.quality.begin(record, "compositor-recovery") - record["next_at"] = 0 - self.persist() - - def persist(self): - atomic_json(self.root / "state.json", self.state) - - def public(self, r): - fields = ("computer", "profile", "generation", "operation", "desired", "observed", "assignment", "error", - "attempts", "next_at", "pid", "window", "resolved", "journal", "evidence", "host_health", "audio_health") - out = {k: r[k] for k in fields if k in r} - out["version"] = 1 - out["requested"] = {k: v for k, v in r["settings"].items() if k in - ("stream_resolution", "fps", "bitrate", "codec", "decoder", "hdr", "yuv444", "input", "system_keys", "audio", "keep_awake", "display")} - mac = r["config"].get("platform") == "macos" or r["settings"].get("display", {}).get("adapter") in ("betterdisplay", "macos") - out["clipboard"] = {"state": "unsupported" if mac else "unverified", - "reason": "Stock Sunshine on macOS does not implement clipboard text input" if mac else None} - out["quality"] = self.quality.report(r) - out["next_actions"] = (["focus", "disconnect"] if r.get("window") else ["retry", "disconnect"]) if r["desired"] else ["connect"] - if r.get("journal"): - out["next_actions"] += ["restore", "release --keep-host-settings"] - return out - - def command(self, request): - if self.manage_scenes: - self.browser.before_command(request) - if request.get("command") not in ("status", "stop"): - self.finish_swap() - 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") - with (self.root / (computer + ".gate")).open("a") as gate: - fcntl.flock(gate, fcntl.LOCK_EX) - return self._command(request) - return self._command(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: - require(computer in self.records, "computer has no managed session") - return self.public(self.records[computer]) - return {"version": 1, "computers": [self.public(r) for r in self.records.values()], "instance": self.compositor.instance} - if action == "stop": - self.running = False # Restart preserves ownership and the running views. - return {"stopped": True} - if action == "swap": - plan = self.compositor.call("stream_swap_plan", {"windows": request["windows"]}) - sources = [w for w in plan["windows"] if w.get("computer")] - require(sources, "swap requires a managed stream window") - for w in sources: - r = self.records.get(w["computer"]) - require(r and r["desired"] and r["phase"] == "watching", "stream is not ready to swap") - require(r.get("window") == {k: w[k] for k in ("address", "pid", "stable_id")}, - "stream window identity changed") - assignment = r["assignment"] - require(all(assignment.get(k) == v for k, v in - {"workspace": plan["workspace"], "layout": plan["layout"], "zone": w["before"]}.items()) - and (not assignment.get("zone_id") or assignment["zone_id"] == w.get("before_id")), - "stream assignment changed") - self.state["swap"] = {"plan": plan, "instance": self.compositor.instance} - self.persist() # Durable before either reservation or local pin changes. - self.finish_swap() - return {"swapped": True, "computers": [self.public(self.records[w["computer"]]) for w in sources]} - if action == "session-restore": - outcomes = [] - for source in request.get("sources", []): - # Existing durable intent wins, including an explicit disconnect. - if source["computer"] not in self.records: - try: - outcomes.append(self.command({"command": "connect", **source})) - except (ValueError, RuntimeError) as error: - require(NAME.fullmatch(source["computer"]), "invalid restored computer ID") - try: - computers = configuration(self.config) - except (OSError, ValueError): - computers = {} - c = computers.get(source["computer"], {}) - settings = c.get("profiles", {}).get(source["profile"], {}) - # A fresh controller may start before local windows create - # their workspaces. Keep the reference even when it cannot - # yet connect, so the next checkpoint cannot erase it. - r = {"computer": source["computer"], "profile": source["profile"], - "assignment": {k: source[k] for k in ("workspace", "layout", "zone")}, - "config": c, "settings": settings, "desired": True, "generation": 1, - "operation": uuid.uuid4().hex, "instance": self.compositor.instance, - "phase": "waiting-workspace" if settings else "unresolved", - "observed": "needs-attention", "error": str(error), "attempts": 0, - "next_at": 0, "waiting_until": self.now() + 45} - self.records[source["computer"]] = r - self.persist() - outcomes.append(self.public(r)) - 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"): - require(r, "computer has no managed session") - if action == "measure": - require(r["desired"] and r["phase"] == "watching", "Measure while the stream window is ready") - self.quality.measure(r, request.get("seconds", 30)) - if action == "readability": - self.quality.assess(r, request["value"]) - if action != "quality": - self.persist() - return self.quality.report(r) - if action == "local": - require(r and r["desired"] and r.get("window"), "stream has no ready window") - return self.compositor.call("stream_local", {"computer": computer}) - if action in ("clipboard", "input-release", "stats"): - require(r and r["desired"] and r.get("window"), "stream has no ready window") - capability = self.public(r)["clipboard"] - require(action != "clipboard" or capability["state"] != "unsupported", capability["reason"]) - 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, - "profile": request["profile"]}) - if action == "connect": - computers = configuration(self.config) - require(computer in computers, "unknown computer; edit computers.json") - c = computers[computer] - profile = request.get("profile") or next(iter(c["profiles"])) - require(profile in c["profiles"], "unknown profile") - require(request.get("zone"), "connect requires --zone") - snap = self.compositor.snapshot() - workspace = str(request.get("workspace") or snap["workspace"]) - ws = next((w for w in snap["workspaces"] if w["selector"] == workspace), None) - require(ws, "assignment-invalid: workspace must already exist") - assignment = {"workspace": workspace, "layout": ws["layout"], "zone": request["zone"]} - if r and r["desired"]: - require(r["profile"] == profile and all(r["assignment"].get(k) == v for k, v in assignment.items()), "computer already owned; disconnect before changing profile or assignment") - if r.get("window"): - self.compositor.call("stream_focus", {"computer": computer}) - return self.public(r) - require(not r or (r["phase"] == "idle" and not self.processes.pid(r)), "disconnect is still in progress") - require(not r or not r.get("journal"), "restore-pending: restore or release existing host settings before connecting") - for other in self.records.values(): - require(not other["desired"] or other["assignment"] != assignment, "zone already reserved") - checked = self.compositor.call("stream_check", {**assignment, "computer": computer}) - if isinstance(checked, dict) and checked.get("zone_id"): - assignment["zone_id"] = checked["zone_id"] - r = {"computer": computer, "profile": profile, "settings": c["profiles"][profile], "config": c, - "assignment": assignment, "generation": (r or {}).get("generation", 0) + 1, - "operation": uuid.uuid4().hex, "desired": True, "phase": "preflight", "observed": "preflight", - "attempts": 0, "next_at": 0, "instance": self.compositor.instance} - self.records[computer] = r - self.quality.begin(r, "connect") - elif action == "reconnect": - require(r and r["desired"], "use connect for a disconnected computer") - if r.get("reconnecting"): - return self.public(r) - require(r["phase"] == "watching" and self.processes.pid(r), "stream is not ready; use retry after it stops") - if request.get("repair_display"): - host = self.host_factory(r["config"], r["settings"]) - require(host.display["adapter"] == "betterdisplay", "display repair requires a managed Mac display") - health = host.remote("probe") - r["display_recovery"] = {"current": health["current"], "topology": health.get("topology")} - r.update(phase="reconnect-stop", observed="reconnecting", next_at=0, reconnecting=True, - stopping_at=self.now(), close_requested=False, error=None, - generation=r["generation"] + 1, operation=uuid.uuid4().hex) - self.quality.begin(r, request.get("reason", "reconnect")) - elif action in ("disconnect", "restore", "release"): - require(r, "computer has no managed session") - if action == "release": - require(request.get("keep_host_settings"), "release requires --keep-host-settings to acknowledge retained host settings") - require(not r["desired"] and not self.processes.pid(r), "disconnect before releasing the host journal") - r["journal"] = {} - r["observed"], r["phase"], r["error"] = "disconnected", "idle", None - else: - r.pop("reconnecting", None) - r.update(desired=False, generation=r["generation"] + 1, operation=uuid.uuid4().hex, - phase="stopping", observed="restoring", next_at=0, stopping_at=self.now(), error=None) - elif action == "focus": - require(r and r["desired"], "computer is disconnected") - self.compositor.call("stream_focus", {"computer": computer}) - elif action == "retry": - require(r and r["desired"], "use connect for a disconnected computer") - require(not self.processes.pid(r), "stream is still running; disconnect it first") - if not r.get("journal"): - computers = configuration(self.config) - require(computer in computers and r["profile"] in computers[computer]["profiles"], "configure the saved computer and profile first") - r["config"] = computers[computer] - r["settings"] = computers[computer]["profiles"][r["profile"]] - r.update(phase="preflight", observed="preflight", next_at=0, attempts=0, error=None, - token=None, generation=r["generation"] + 1, operation=uuid.uuid4().hex) - r.pop("reconnecting", None) - self.quality.begin(r, "retry") - else: - raise ValueError("unknown stream command") - self.persist() - return self.public(r) - - def finish_swap(self): - pending = self.state.get("swap") - if not pending: - return - plan = pending["plan"] - if pending["instance"] != self.compositor.instance: - # Window references cannot be replayed into a new compositor. - self.state.pop("swap") - self.persist() - return - try: - self.compositor.call("stream_swap_apply", plan) - except RuntimeError as error: - if "swap unavailable:" in str(error): - self.compositor.call("stream_swap_cancel", plan) - self.state.pop("swap") - self.applied.clear() - self.persist() - raise - # Assignment edits do not change the launch generation/token, profile, - # PID or host restoration journal. An IPC retry applies absolute zones. - for w in plan["windows"]: - if w.get("computer"): - r = self.records[w["computer"]] - r["assignment"]["zone"] = w["zone"] - if w.get("zone_id"): - r["assignment"]["zone_id"] = w["zone_id"] - else: - r["assignment"].pop("zone_id", None) - self.applied.pop(w["computer"], None) - if self.manage_scenes: - self.scenes.swapped() - self.state.pop("swap") - self.persist() - - def assign(self, r, window=None): - args = {**r["assignment"], "computer": r["computer"], "profile": r["profile"], "title": r["config"]["title"]} - if window: - args.update({k: window[k] for k in ("address", "pid", "stable_id", "title")}) - previous = r.get("window", {}) - args["placed"] = all(previous.get(k) == window[k] for k in ("address", "pid", "stable_id")) - if self.applied.get(r["computer"]) != args: - self.compositor.call("stream_assign", args) - if window: - args["placed"] = True - self.applied[r["computer"]] = args - - def release_zone(self, r): - self.compositor.call("stream_release", {"computer": r["computer"]}) - self.applied.pop(r["computer"], None) - r.pop("window", None) - - def failure(self, r, error): - r["error"] = str(error) - if "host-unreachable" in str(error) and r["desired"] and r.get("attempts", 0) < 3: - r["attempts"] = r.get("attempts", 0) + 1 - r.update(phase="preflight", observed="reconnecting", next_at=self.now() + (2, 5, 15)[r["attempts"] - 1]) - else: - r.update(phase="failed-restore", observed="needs-attention", next_at=0) - - def step(self, r, snap): - phase, now = r["phase"], self.now() - if r.get("next_at", 0) > now: - return - pid = self.processes.pid(r) - r["pid"] = pid - if pid: - r.pop("missing_since", None) - else: - r.setdefault("missing_since", now) - evidence = self.processes.events(r) - # SDL's explicit quit can precede logger EOF (a helper may retain the - # pipe). Don't turn a normal close into launch uncertainty in that gap. - if r["desired"] and phase != "reconnect-stop" and not pid and evidence.get("quit") and "terminated" not in evidence: - self.command({"command": "disconnect", "computer": r["computer"]}) - phase = r["phase"] - if phase == "restart-stop": - if pid: - self.processes.stop(r, force=True) - return - r.update(phase="waiting-workspace", token=None, next_at=0, waiting_until=now + 45) - return - if not r["desired"]: - self.release_zone(r) - if (phase == "idle" and r.get("journal", {}).get("windows") - and now >= r.get("next_restore_check", 0)): - r["phase"] = "restoring" - if phase == "stopping": - if pid: - self.processes.stop(r, force=now - r["stopping_at"] > 5) - return - r["phase"] = "restoring" - if r["phase"] == "restoring": - try: - done = restore(r, self.host_factory(r["config"], r["settings"]), self.persist) - r["next_restore_check"] = now + 5 - r.update(phase="idle", observed="disconnected" if done else "restore-pending", - error=None if done else ((r.get("resolved", {}).get("display", {}).get("error") or "physical-display-restoration-pending: open the lid or attach a monitor") - if r.get("journal", {}).get("windows") else "restore-conflict: manual changes preserved")) - except (OSError, ValueError, subprocess.TimeoutExpired) as error: - r["next_restore_check"] = now + 10 - r.update(phase="idle", observed="restore-pending", error=str(error)) - return - if phase == "reconnect-stop": - if pid: - if not r.get("close_requested"): - # Persist first: a repeated close after a lost reply is harmless. - r["close_requested"] = True - self.persist() - self.compositor.call("stream_close", {"computer": r["computer"]}) - elif now - r["stopping_at"] > 5: - self.processes.stop(r, force=now - r["stopping_at"] > 8) - return - if not evidence.get("closed") and now - r["stopping_at"] < 8: - return # Give the logger time to publish its final decoder summary. - r.pop("window", None) - r.update(token=None, phase="preflight", observed="reconnecting", next_at=0) - self.assign(r) # Keep the zone reserved while the client restarts. - return - if phase == "unresolved": - return - if phase == "waiting-workspace": - if any(w["selector"] == r["assignment"]["workspace"] for w in snap["workspaces"]): - r.update(phase="preflight", observed="preflight", error=None) - elif now > r["waiting_until"]: - raise ValueError("assignment-invalid: workspace did not return during session recovery") - return - if phase == "attention" and r.get("journal", {}).get("windows"): - if now >= r.get("next_restore_check", 0): - r["next_restore_check"] = now + 10 - done = restore(r, self.host_factory(r["config"], r["settings"]), self.persist) - r["observed"] = "needs-attention" if done else "restore-pending" - return # Finishing recovery never restarts a failed stream. - if phase == "failed-restore": - if pid: - r.setdefault("stopping_at", now) - self.processes.stop(r, force=now - r["stopping_at"] > 5) - return - done = restore(r, self.host_factory(r["config"], r["settings"]), self.persist) - r["next_restore_check"] = now + 5 - r.update(phase="attention", observed="needs-attention" if done else "restore-pending") - return - # Re-establish reservations after a compositor reload, even when offline. - known = {s["computer"]: s for s in snap.get("streams", [])} - if r["computer"] not in known: - self.applied.pop(r["computer"], None) - self.inhibitors.pop(r["computer"], None) - checked = self.compositor.call("stream_check", {**r["assignment"], "computer": r["computer"]}) - if isinstance(checked, dict) and checked.get("zone") != r["assignment"]["zone"]: - r["assignment"]["zone"] = checked["zone"] - self.applied.pop(r["computer"], None) - if phase == "preflight": - self.assign(r) - # Don't compete with a manually launched view of this host. - require(not any(w["class"] == CLASS and w["title"] == r["config"]["title"] and w.get("pid") != pid - for w in snap["windows"]), "unmanaged-stream: close the existing view before connecting") - info = self.host_factory(r["config"], r["settings"]).probe() - r["resolved"] = {k: v for k, v in info.items() if k != "modes"} - r.update(phase="preparing", observed="preparing-display", error=None) - elif phase == "preparing": - self.assign(r) - prepare(r, self.host_factory(r["config"], r["settings"]), self.persist) - r.update(phase="launch", observed="connecting") - r["next_host_probe"] = now + (5 if r["settings"].get("display", {}).get("adapter") in ("betterdisplay", "macos") else 30) - elif phase == "launch": - # The token and launch intent survive a dispatch timeout or crash. - if not r.get("token") or self.processes.events(r).get("closed"): - r["token"] = uuid.uuid4().hex - r.update(phase="connecting", observed="connecting", launched_at=now) - self.persist() - if not pid: - self.processes.launch(r) - elif phase in ("connecting", "watching"): - evidence = self.processes.events(r) - r["evidence"] = evidence - windows = [w for w in snap["windows"] if w.get("pid") == pid and w["class"] == CLASS] if pid else [] - final = [w for w in windows if w["title"] == r["config"]["title"]] - if len(final) == 1: - w = final[0] - if w.get("workspace") != r["assignment"]["workspace"]: - self.applied.pop(r["computer"], None) - self.assign(r, w) - r["window"] = {k: w[k] for k in ("address", "pid", "stable_id")} - r.update(phase="watching", observed="window-ready", error=None) - r.pop("reconnecting", None) - if r["settings"].get("audio") == "host" and now >= r.get("next_audio_check", 0): - r["audio_health"] = host_headset(pid) - r["next_audio_check"] = now + 5 - if r["settings"].get("display", {}).get("adapter") == "windows" and now >= r.get("next_host_probe", 0): - r["next_host_probe"] = now + 15 - try: - health = self.host_factory(r["config"], r["settings"]).remote("status") - except (ValueError, subprocess.TimeoutExpired) as error: - r["host_health"] = {"state": "unknown", "error": str(error)} - else: - require(not health.get("error"), health.get("error", "Windows display helper error")) - require(health.get("phase") in ("preparing", "streaming"), "display-restored: reconnect the Windows stream") - r["host_health"] = {"state": "checked", "at": now} - if r["settings"].get("display", {}).get("adapter") in ("betterdisplay", "macos") and now >= r.get("next_host_probe", 0): - r["next_host_probe"] = now + 5 - host = self.host_factory(r["config"], r["settings"]) - try: - health = host.remote("probe") - except ValueError as error: - if "host-unreachable" not in str(error): - raise - r["host_health"] = {"state": "unknown", "error": str(error)} - except subprocess.TimeoutExpired: - r["host_health"] = {"state": "unknown", "error": "SSH probe timed out"} - else: - require(not host.display.get("require_ac") or health["ac_power"], "power-required: host lost AC power") - before, after = r.get("mac_topology") or {}, health.get("topology") or {} - lid_changed = (type(before.get("lid_closed")) is bool and type(after.get("lid_closed")) is bool - and before["lid_closed"] != after["lid_closed"]) - following = host.display.get("follow_main", host.display["adapter"] == "macos") - capture_changed = following and before and ( - before.get("display_id") != after.get("display_id") or - before.get("display_uuid") != after.get("display_uuid")) - unmanaged_mode_changed = (following and not manages_mode(host.display, health) and - not same_setting("mode", health["current"]["mode"], r["resolved"]["current"]["mode"])) - if lid_changed or capture_changed or unmanaged_mode_changed: - require(health["current"]["output"] == r["resolved"]["current"]["output"], - "capture-display-changed: capture output changed during lid transition") - r["display_recovery"] = {"current": health["current"], "topology": health["topology"]} - self.command({"command": "reconnect", "computer": r["computer"], - "reason": "main-display-change" if following else "lid-change"}) - return # Stop the local client before refreshing Sunshine's display/input state. - require(health["current"]["output"] == health["identity"]["displayID"], "capture-display-changed: check Sunshine configuration") - r["mac_topology"] = health.get("topology") - r["host_health"] = {"state": "checked", "at": now} - if manages_mode(host.display, health) and not same_setting("mode", health["current"]["mode"], host.display["mode"]): - r["host_health"] = {"state": "unknown", "error": "display-mode-changed: use reconnect --repair-display to restore the stream size"} - r["resolved"].update({k: v for k, v in health.items() if k != "modes"}) - if r.get("host_health", {}).get("state") == "unknown": - r["observed"] = "degraded" - elif windows: - self.assign(r) - r["observed"] = "startup-window" - else: - r.pop("window", None) - self.assign(r) - if evidence.get("closed") and not pid: - # Unknown/abnormal exits never trigger automatic reconnect. Only - # explicit Moonlight connection-loss codes are retry candidates. - code = evidence.get("terminated") - if code == -100 and r.get("attempts", 0) < 3: - r["token"] = None - self.quality.begin(r, "network-recovery") - self.failure(r, ValueError("host-unreachable: Moonlight connection lost")) - elif evidence.get("quit") and code is None: - self.command({"command": "disconnect", "computer": r["computer"]}) - else: - r.update(phase="failed-restore", observed="needs-attention", error="stream-exited: retry or disconnect") - elif not pid and now - r["missing_since"] > (15 if phase == "connecting" else 3): - r.update(phase="failed-restore", observed="needs-attention", error="launch-uncertain: retry or disconnect") - elif not final and now - r["launched_at"] > 60: - self.processes.stop(r) - r.update(phase="failed-restore", observed="needs-attention", error="window-timeout: check Moonlight and host permissions") - else: - self.assign(r) - - def tick(self): - if self.manage_scenes: - self.browser.tick() - self.finish_swap() - before = json.dumps(self.state, sort_keys=True) - self.quality.harvest() - self.quality.due() - if self.manage_scenes: - self.scenes.tick() - snap = self.compositor.snapshot() - for r in self.records.values(): - 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: - self.step(r, snap) - except (OSError, ValueError, RuntimeError, KeyError, subprocess.TimeoutExpired) as error: - if r["phase"] in ("failed-restore", "attention") or "assignment-invalid" in str(error): - if self.processes.pid(r): - self.processes.stop(r) - if "assignment-invalid" in str(error): - self.release_zone(r) - r.update(phase="attention" if r["phase"] == "failed-restore" else "failed-restore", - observed="needs-attention", error=str(error)) - elif not r["desired"]: - r.update(phase="idle", observed="restore-pending", error=str(error)) - else: - self.failure(r, error) - finally: - self.quality.observe(r, phase, started, snap) - if before != json.dumps(self.state, sort_keys=True): - self.persist() - self.keep_awake(snap) - - def keep_awake(self, snap): - visible = {w["selector"] for w in snap["workspaces"] if w.get("visible")} - for r in self.records.values(): - if not r.get("window"): - self.inhibitors.pop(r["computer"], None) - continue - policy = r["settings"].get("keep_awake", "visible") - needed = r["desired"] and (policy == "always" or (policy == "visible" and r["assignment"]["workspace"] in visible)) - value = (r["window"]["stable_id"], needed) - if self.inhibitors.get(r["computer"]) != value: - self.compositor.call("stream_inhibit", {"computer": r["computer"], "enabled": needed}) - self.inhibitors[r["computer"]] = value - - def tick_interval(self): - # Observe transitions promptly; keep retry backoff and steady polling. - moving = {"preflight", "preparing", "launch", "connecting", "reconnect-stop", - "stopping", "restoring", "restart-stop", "failed-restore"} - return .2 if any(r["phase"] in moving and r.get("next_at", 0) <= self.now() - for r in self.records.values()) else 1 - - - -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") - commands = parser.add_subparsers(dest="command", required=True) - for name in ("daemon", "stop", "computers"): - p = commands.add_parser(name) - p.add_argument("--json", action="store_true") - p = commands.add_parser("launch-job", help=argparse.SUPPRESS) - p.add_argument("path", type=Path) - p = commands.add_parser("swap", help="exchange two ready windows; normally invoked by Super+Shift+Arrow") - p.add_argument("address") - p.add_argument("stable_id", type=int) - p.add_argument("target") - p.add_argument("target_stable_id", type=int) - p.add_argument("--json", action="store_true") - p = commands.add_parser("scene", help="save and apply workspace scenes") - subs = p.add_subparsers(dest="action", required=True) - for action in ("list", "show", "save", "validate", "apply", "current", "restore", "cancel", "retry", "remove", "catalog", "content", "layout", "browse", "browse-end"): - child = subs.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", "stream", "empty"), required=True) - child.add_argument("--computer") - child.add_argument("--profile") - child.add_argument("--app-class") - child.add_argument("--workspace") - child.add_argument("--json", action="store_true") - for name in ("probe", "connect", "focus", "disconnect", "status", "retry", "restore", "release", "profile", "clipboard", "input-release", "stats", "local", "reconnect", "quality", "measure", "readability"): - p = commands.add_parser(name) - p.add_argument("computer", nargs="?" if name == "status" else None) - p.add_argument("--json", action="store_true") - if name in ("probe", "connect", "profile"): - p.add_argument("--profile", required=name == "profile") - if name == "connect": - p.add_argument("--zone", required=True) - p.add_argument("--workspace") - if name == "release": - p.add_argument("--keep-host-settings", action="store_true") - if name == "measure": - p.add_argument("--seconds", type=int, default=30) - if name == "reconnect": - p.add_argument("--repair-display", action="store_true", help="explicitly restore the managed Mac display mode") - if name == "readability": - p.add_argument("value", choices=("readable", "too-small", "blurry")) - args = parser.parse_args() - if args.command == "swap": - args.windows = [{"address": args.address, "stable_id": args.stable_id}, - {"address": args.target, "stable_id": args.target_stable_id}] - try: - if args.command == "scene" and getattr(args, "file", None): - args.document = json.loads(sys.stdin.read() if args.file == "-" else Path(args.file).read_text()) - if args.command == "scene" and args.action == "validate": - require(getattr(args, "document", None), "validate requires --file FILE (or - for stdin)") - if args.command == "launch-job": - launch_job(args.path) - return - if args.command == "daemon": - daemon(root, runtime, config, lambda r, c, h: Controller(r, c, h, manage_scenes=False)) - return - if args.command == "computers": - computers = configuration(config) - paired = moonlight_hosts() - result = {"config": str(config), "computers": [{"computer": k, "host": v["host"], - "pairing_uuid": v["pairing_uuid"], "paired": paired.get(v["pairing_uuid"].lower(), {}).get("paired", False), - "profiles": list(v["profiles"])} for k, v in computers.items()]} - elif args.command == "probe": - computers = configuration(config) - require(args.computer in computers, "unknown computer") - c = computers[args.computer] - profile = args.profile or next(iter(c["profiles"])) - require(profile in c["profiles"], "unknown profile") - result = {"computer": args.computer, "profile": profile, **Host(c, c["profiles"][profile]).probe()} - else: - if args.command not in ("status", "stop"): - try: - request(runtime, {"command": "status"}, timeout=1) - except (OSError, ValueError): - subprocess.Popen([sys.executable, str(Path(sys.argv[0]).resolve()), "daemon"], - 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) - result = request(runtime, vars(args)) - if getattr(args, "json", False): - print(json.dumps(result, indent=2)) - elif "computers" in result: - for r in result["computers"]: - print(r["computer"], r.get("observed", "paired" if r.get("paired") else "pairing-required"), - r.get("profile", ", ".join(r.get("profiles", []))), sep="\t") - else: - print(json.dumps(result, indent=2)) - except (OSError, ValueError, KeyError, RuntimeError, subprocess.TimeoutExpired) as error: - print("hypertile-stream: " + str(error), file=sys.stderr) - if args.command == "swap" and shutil.which("notify-send"): - subprocess.run(["notify-send", "Hypertile swap", str(error)], check=False) - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/stream/mac_display.py b/stream/mac_display.py deleted file mode 100644 index d70ab6b..0000000 --- a/stream/mac_display.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Typed Mac display operations, sent to the user's approved SSH account. - -No persistent remote agent is installed. REQUEST is supplied by the client. -Only the display mode and Sunshine output_name can be changed. -""" -import json -import ctypes -import fcntl -import os -from pathlib import Path -import re -import subprocess -import tempfile -import time -import uuid - -BETTER = "/Applications/BetterDisplay.app/Contents/MacOS/BetterDisplay" - - -def same_setting(field, a, b): - if field != "mode": - return a == b - # EDID/CLI mode lists round nominal timings (60 can read back as 59.95). - return (isinstance(a, dict) and isinstance(b, dict) and a.get("resolution") == b.get("resolution") - and a.get("hidpi") == b.get("hidpi") - and (a["refresh"] == b["refresh"] or - (type(a["refresh"]) in (int, float) and type(b["refresh"]) in (int, float) - and abs(a["refresh"] - b["refresh"]) < .15))) - - -def manages_mode(display, observed): - return display.get("adapter") != "macos" and (not display.get("follow_main") or observed["identity"]["UUID"].lower() == display["uuid"].lower()) - - -def run(argv): - p = subprocess.run(argv, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=8) - if p.returncode or p.stdout.strip() == "Failed.": - raise ValueError("display-operation-failed: check BetterDisplay, display connection and permissions") - return p.stdout.strip() - - -def restart_sunshine(): - subprocess.run(["/usr/bin/pkill", "-TERM", "-x", "Sunshine"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - for _ in range(30): - if subprocess.run(["/usr/bin/pgrep", "-x", "Sunshine"], stdout=subprocess.DEVNULL).returncode: - break - time.sleep(.1) - else: - raise ValueError("Sunshine did not stop; display restore may be pending") - run(["/usr/bin/open", "/Applications/Sunshine.app"]) - - -def lid_closed(): - result = run(["/usr/sbin/ioreg", "-r", "-k", "AppleClamshellState", "-d", "4"]) - match = re.search(r'"AppleClamshellState"\s*=\s*(Yes|No)', result) - return match[1] == "Yes" if match else None - - -def native_display(): - """Read the primary screen through CoreGraphics; never change its mode.""" - cg = ctypes.CDLL("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics") - def bind(lib, name, result, *args): - fn = getattr(lib, name) - fn.restype, fn.argtypes = result, list(args) - return fn - u32, ptr = ctypes.c_uint32, ctypes.c_void_p - main = bind(cg, "CGMainDisplayID", u32)() - if not bind(cg, "CGDisplayIsActive", u32, u32)(main): - raise ValueError("display-missing: main display is inactive") - # Native profiles own only the current capture output, not a persistent - # physical display mode. Include the live ID and hardware identifiers so - # a changed primary screen cannot reuse another screen's write guard. - hardware = [bind(cg, name, u32, u32)(main) for name in - ("CGDisplayVendorNumber", "CGDisplayModelNumber", "CGDisplaySerialNumber")] - identity = str(uuid.uuid5(uuid.NAMESPACE_URL, "coregraphics:" + str([main, *hardware]))).upper() - mode = bind(cg, "CGDisplayCopyDisplayMode", ptr, u32)(main) - if not mode: - raise ValueError("display-missing: main display has no mode") - try: - values = {key: bind(cg, "CGDisplayModeGet" + key, ctypes.c_size_t, ptr)(mode) - for key in ("Width", "Height", "PixelWidth", "PixelHeight")} - refresh = bind(cg, "CGDisplayModeGetRefreshRate", ctypes.c_double, ptr)(mode) - finally: - bind(cg, "CGDisplayModeRelease", None, ptr)(mode) - built_in = bool(bind(cg, "CGDisplayIsBuiltin", u32, u32)(main)) - return ({"UUID": identity, "displayID": str(main), "name": "Built-in Display" if built_in else "External Display"}, - {"resolution": f'{values["Width"]}x{values["Height"]}', - "hidpi": values["PixelWidth"] > values["Width"], - "refresh": refresh if refresh else "variable"}, - f'{values["PixelWidth"]}x{values["PixelHeight"]}') - - -def display(request): - state = json.loads((Path.home() / ".config/sunshine/sunshine_state.json").read_text()) - identity = state.get("root", {}).get("uniqueid", "") - if identity.lower() != request["pairing_uuid"].lower(): - raise ValueError("host-identity-mismatch: SSH host is not the paired Sunshine computer") - native = request.get("adapter") == "macos" - if native: - ids, native_mode, render_resolution = native_display() - identity = ids["UUID"] - if request.get("expected_identity") and identity.lower() != request["expected_identity"].lower(): - raise ValueError("display-topology-changed: main display changed before the operation") - else: - graphics = ctypes.CDLL("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics") - graphics.CGMainDisplayID.restype = ctypes.c_uint32 - identity = request["display_uuid"] - if request.get("follow_main"): - ids = json.loads(run([BETTER, "get", "-type=Display", "-displayID=" + str(graphics.CGMainDisplayID()), "-identifiers"])) - identity = ids["UUID"] - if request.get("expected_identity") and identity.lower() != request["expected_identity"].lower(): - raise ValueError("display-topology-changed: main display changed before the operation") - if not re.fullmatch(r"[A-Fa-f0-9-]{36}", identity): - raise ValueError("invalid display UUID") - def get(key): - # UUID alone also matches BetterDisplay's default display group. - # Restrict all reads and writes to a physical Display before resolving. - return run([BETTER, "get", "-type=Display", "-UUID=" + identity, "-" + key]) - try: - ids = json.loads(get("identifiers")) - except (ValueError, KeyError): - raise ValueError("display-missing: selected display is unavailable") from None - if not isinstance(ids, dict) or ids.get("UUID", "").lower() != identity.lower(): - raise ValueError("display-missing: no unique UUID match") - graphics.CGDisplayIsActive.argtypes = [ctypes.c_uint32] - graphics.CGDisplayIsActive.restype = ctypes.c_uint32 - if not graphics.CGDisplayIsActive(int(ids["displayID"])): - raise ValueError("display-missing: selected display is disconnected") - config = Path.home() / ".config/sunshine/sunshine.conf" - text = config.read_text() - outputs = re.findall(r"^\s*output_name\s*=\s*(.*?)\s*$", text, re.M) - if len(outputs) > 1: - raise ValueError("display-configuration-invalid: duplicate output_name") - def get_mode(): - if native: - return native_mode - refresh = get("refreshRate") - return {"resolution": get("resolution"), "hidpi": get("hiDPI") == "on", - "refresh": refresh if refresh == "ProMotion" else float(refresh.removesuffix("Hz"))} - mode = get_mode() - current = {"mode": mode, "output": outputs[0] if outputs else None} - if request["operation"] == "probe": - modes = [] - for line in ([] if native else get("displayModeList").splitlines()): - m = re.fullmatch(r"\d+ - (\d+x\d+)( HiDPI)? (\d+(?:\.\d+)?)Hz.*", line.strip()) - if m: - modes.append({"resolution": m[1], "hidpi": bool(m[2]), "refresh": float(m[3])}) - power = run(["/usr/bin/pmset", "-g", "batt"]) - return {"identity": ids, "current": current, "modes": modes, - "topology": {"lid_closed": lid_closed(), "display_id": ids["displayID"], "display_uuid": identity}, - "sunshine_uuid": request["pairing_uuid"], - "ac_power": "AC Power" in power, "permissions": "unknown", - "render_resolution": render_resolution if native else "x".join(str(int(v) * (2 if mode["hidpi"] else 1)) for v in mode["resolution"].split("x"))} - if request["operation"] == "refresh": - if not all(same_setting(k, current[k], request["expected"][k]) for k in current): - raise ValueError("restore-conflict: capture changed before refreshing input") - restart_sunshine() - return {"refreshed": ids["displayID"]} - field, expected, value = request["field"], request["expected"], request["value"] - # Compare and change in one remote invocation; preserve a manual change. - if field not in current or not same_setting(field, current[field], expected): - raise ValueError("restore-conflict: current setting differs from the journal") - if field == "mode": - if native: - raise ValueError("unsupported display field: native desktop preserves the host mode") - if not re.fullmatch(r"[0-9]{3,5}x[0-9]{3,5}", value["resolution"]): - raise ValueError("invalid resolution") - if type(value["hidpi"]) is not bool or not (value["refresh"] == "ProMotion" or - (type(value["refresh"]) in (int, float) and 20 <= value["refresh"] <= 240)): - raise ValueError("invalid display mode") - run([BETTER, "set", "-type=Display", "-UUID=" + identity, "-resolution=" + value["resolution"], - "-hiDPI=" + ("on" if value["hidpi"] else "off"), "-refreshRate=" + str(value["refresh"])]) - if not same_setting("mode", get_mode(), value): - raise ValueError("display-readback-failed: restoration required") - # Sunshine's macOS input context caches displayScaling at startup. - # A mode switch must refresh it even when output_name stays the same. - restart_sunshine() - elif field == "output": - if value is not None and not re.fullmatch(r"\d{1,10}", value): - raise ValueError("invalid capture output") - updated = re.sub(r"^\s*output_name\s*=.*\n?", "", text, flags=re.M) - if value is not None: - updated = updated.rstrip() + "\noutput_name = " + value + "\n" - fd, path = tempfile.mkstemp(dir=config.parent) - try: - with os.fdopen(fd, "w") as out: - out.write(updated) - out.flush() - os.fsync(out.fileno()) - os.replace(path, config) - finally: - if os.path.exists(path): - os.unlink(path) - restart_sunshine() - else: - raise ValueError("unsupported display field") - return {"changed": field} - - -if __name__ == "__main__": - try: - # A client timeout can leave a remote operation finishing its write. - # Serialize probes too: restoration cannot observe the old value and - # discard its journal while an earlier write is still in flight. - with (Path.home() / ".config/sunshine/hypertile-display.lock").open("a") as lock: - os.fchmod(lock.fileno(), 0o600) - fcntl.flock(lock, fcntl.LOCK_EX) - result = display(REQUEST) - print(json.dumps({"ok": True, "result": result})) - except Exception as error: - print(json.dumps({"ok": False, "error": str(error)})) diff --git a/stream/quality.py b/stream/quality.py deleted file mode 100644 index d21e9dd..0000000 --- a/stream/quality.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Bounded, typed measurements; no raw logs, screen content or automatic tuning.""" -import copy -import hashlib -import json -import math -from pathlib import Path -import re -import time -import uuid - - -class VideoStats: - """Moonlight Qt 6.1's completed FFmpeg decoder summary (not live telemetry).""" - PREFIX = re.compile(r"^\d\d:\d\d:\d\d(?:\.\d+)? - SDL Info \(\d+\): ") - NUMBER = r"(\d+(?:\.\d+)?)" - FIELDS = { - "Incoming frame rate from network": ("received_fps", "FPS", 1000), - "Decoding frame rate": ("decoded_fps", "FPS", 1000), - "Rendering frame rate": ("rendered_fps", "FPS", 1000), - "Frames dropped by your network connection": ("network_drop_pct", "%", 100), - "Frames dropped due to network jitter": ("jitter_drop_pct", "%", 100), - "Average decoding time": ("decode_ms", "ms", 60000), - "Average frame queue delay": ("queue_ms", "ms", 60000), - "Average rendering time (including monitor V-sync latency)": ("render_ms", "ms", 60000), - } - - def __init__(self): - self.current = None - - def feed(self, line): - line = self.PREFIX.sub("", line.strip()) - if line == "Global video stats": - self.current = {} - return None - if self.current is None or len(line) > 300: - return None - for label, (key, unit, limit) in self.FIELDS.items(): - match = re.fullmatch(re.escape(label) + ": " + self.NUMBER + ("" if unit == "%" else " ") + re.escape(unit), line) - if match: - value = float(match[1]) - if math.isfinite(value) and 0 <= value <= limit: - self.current[key] = value - if key == "render_ms": - result, self.current = self.current, None - # Truncated output and non-finite metrics never become a complete result. - if all(k in result for k in ("rendered_fps", "network_drop_pct", "jitter_drop_pct", "decode_ms", "queue_ms", "render_ms")): - return result - return None - match = re.fullmatch("Host processing latency min/max/average: " + "/".join([self.NUMBER] * 3) + " ms", line) - if match: - lo, hi, avg = map(float, match.groups()) - if 0 <= lo <= avg <= hi <= 60000: - self.current["host_processing_ms"] = {"min": lo, "max": hi, "average": avg} - match = re.fullmatch(r"Average network latency: (\d+) ms \(variance: (\d+) ms\)", line) - if match and max(map(int, match.groups())) <= 60000: - self.current.update(network_rtt_ms=int(match[1]), network_variance_ms=int(match[2])) - return None - - -def settings_key(settings): - return hashlib.sha256(json.dumps(settings, sort_keys=True, separators=(",", ":")).encode()).hexdigest() - - -class Tracker: - def __init__(self, controller, clock=time.monotonic, boot=None): - self.ctl, self.clock = controller, clock - self.boot = boot or Path("/proc/sys/kernel/random/boot_id").read_text().strip() - self.records = controller.state.setdefault("quality", {}) - - def runs(self, computer): - return self.records.setdefault(computer, {"runs": []})["runs"] - - def current(self, r): - return next((v for v in self.runs(r["computer"]) if v["id"] == r.get("quality_id")), None) - - def begin(self, r, reason): - run = {"id": uuid.uuid4().hex, "profile": r["profile"], "settings_key": settings_key(r["settings"]), - "reason": reason, "started_at": self.ctl.now(), "boot": self.boot, - "clock_start": self.clock() if reason != "adopted" else None, - "previous_token": r.get("token") if reason != "adopted" else None, - "stages_ms": {}, "work_ms": {}, "status": "starting", - "requested": {k: r["settings"].get(k, default) for k, default in - (("stream_resolution", None), ("fps", 60), ("bitrate", 60000), ("codec", "HEVC"), - ("input", "absolute"), ("system_keys", "never"), ("audio", "focus"))}} - runs = self.runs(r["computer"]) - runs.append(run) - del runs[:-20] - r["quality_id"] = run["id"] - return run - - def observe(self, r, phase, started, snap): - run = self.current(r) or self.begin(r, "adopted") - if run["boot"] != self.boot: - run["clock_start"] = None - if r.get("token") and r["token"] != run.get("previous_token"): - run["token"] = r["token"] - if phase in ("preflight", "preparing", "launch", "reconnect-stop"): - run["work_ms"][phase] = round(run["work_ms"].get(phase, 0) + max(0, self.clock() - started) * 1000, 1) - elapsed = max(0, self.clock() - run["clock_start"]) * 1000 if run["clock_start"] is not None else None - if elapsed is not None and r["phase"] not in run["stages_ms"]: - run["stages_ms"][r["phase"]] = round(elapsed, 1) - run["status"] = r["observed"] - run["client_version"] = r.get("resolved", {}).get("client_version", "unknown") - if r.get("window") and r["phase"] == "watching": - if "window_ready_ms" not in run: - run["window_ready_ms"] = round(elapsed, 1) if elapsed is not None else None - window = next((w for w in snap["windows"] if w["address"] == r["window"]["address"]), {}) - if window.get("size"): - run["view_size"] = copy.deepcopy(window["size"]) - mode = r.get("resolved", {}).get("current", {}).get("mode") - if mode: - run["host_mode"] = copy.deepcopy(mode) - - def harvest(self): - for computer, record in self.records.items(): - for run in record["runs"]: - token = run.get("token") - if run.get("closed") or not token or not re.fullmatch(r"[a-f0-9]{32}", token): - continue - path = self.ctl.root / (token + ".events") - try: - event = json.loads(path.read_text()) - except (OSError, ValueError): - continue - run["quality_parser"] = event.get("quality_parser") == 1 - if event.get("performance"): - run["metrics"] = copy.deepcopy(event["performance"]) - run["metrics_at"] = event["performance_at"] - run["metrics_source"] = "completed-decoder-segment" - if event.get("closed"): - run["closed"], run["ended_at"] = True, self.ctl.now() - if run.get("measurement", {}).get("status") == "collecting": - run["measurement"]["status"] = "complete" if run.get("metrics") else "no-metrics" - - def measure(self, r, seconds): - if type(seconds) is not int or not 10 <= seconds <= 300: - raise ValueError("measurement duration must be 10–300 seconds") - run = self.current(r) - if not run or not run.get("quality_parser"): - version = r.get("resolved", {}).get("client_version", "unknown") - if version != "unknown" and not version.startswith("6.1."): - raise ValueError("Quality collection supports Moonlight Qt 6.1; use the client's Statistics overlay on this version") - raise ValueError("Reconnect once to enable quality measurements in this client") - if run.get("measurement", {}).get("status") == "recording": - return - run["measurement"] = {"seconds": seconds, "status": "recording", "deadline": self.clock() + seconds, - "boot": self.boot, "token": r["token"]} - - def due(self): - for computer, record in list(self.records.items()): - for run in list(record["runs"]): - measurement = run.get("measurement", {}) - if measurement.get("status") != "recording": - continue - r = self.ctl.records.get(computer, {}) - if measurement["boot"] != self.boot or not r.get("desired") or r.get("token") != measurement["token"] or r.get("phase") != "watching": - measurement["status"] = "cancelled" - elif self.clock() >= measurement["deadline"]: - measurement["status"] = "collecting" - try: - self.ctl.command({"command": "reconnect", "computer": computer, "reason": "measurement"}) - except (ValueError, RuntimeError): - measurement["status"] = "cancelled" - - def assess(self, r, value): - if value not in ("readable", "too-small", "blurry"): - raise ValueError("readability must be readable, too-small, or blurry") - run = self.current(r) - if not run or not r.get("window"): - raise ValueError("Assess readability while the stream window is ready") - run["readability"] = {"value": value, "at": self.ctl.now(), "view_size": copy.deepcopy(run.get("view_size"))} - - def report(self, r): - runs = self.runs(r["computer"]) - current = self.current(r) - def public(run): - out = {k: copy.deepcopy(v) for k, v in run.items() if k not in ("token", "previous_token", "boot", "clock_start")} - if out.get("measurement"): - out["measurement"] = {k: v for k, v in out["measurement"].items() if k in ("status", "seconds")} - return out - # Compare only identical profile settings; a reused profile name is not evidence. - comparable = [v for v in runs if v["settings_key"] == settings_key(r["settings"])] - measured = next((v for v in reversed(comparable) if v.get("metrics")), None) - advice = [] - if measured: - m = measured["metrics"] - if m.get("network_drop_pct", 0) > .5 or m.get("jitter_drop_pct", 0) > .5: - advice.append("Frame loss was measured. Compare a lower bitrate on the same connection before saving a preset.") - if m.get("decode_ms", 0) > 1000 / r["settings"].get("fps", 60): - advice.append("Decoding exceeded one frame interval. Compare a lower resolution or hardware decoder.") - assessment = current.get("readability", {}) if current else {} - if (assessment.get("view_size") != (current or {}).get("view_size") - or (current or {}).get("settings_key") != settings_key(r["settings"])): - assessment = {} - if assessment.get("value") == "too-small": - advice.append("Text is too small at this view size. Enlarge the zone or use a larger host UI scale.") - elif assessment.get("value") == "blurry": - advice.append("Compare stream resolution and host scaling using the same text sample.") - version = r.get("resolved", {}).get("client_version", "unknown") - reason = None if current and current.get("quality_parser") else ( - "Quality collection supports Moonlight Qt 6.1; use the client's Statistics overlay on this version." - if version != "unknown" and not version.startswith("6.1.") else - "Reconnect once to enable collection in an existing stream.") - return {"version": 1, "current": public(current) if current else None, - "last_measurement": public(measured) if measured else None, - "history": [public(v) for v in reversed(runs[-5:])], - "readability": assessment.get("value", "unverified"), "advice": advice, - "collection_reason": reason, - "encode_ms": None, "end_to_end_ms": None, - "limits": "Host processing includes more than encoding. Window-ready timing is not first-frame or end-to-end latency."} diff --git a/stream/windows/Display.cs b/stream/windows/Display.cs deleted file mode 100644 index 745f2c8..0000000 --- a/stream/windows/Display.cs +++ /dev/null @@ -1,116 +0,0 @@ -// Windows CCD display operations. No window automation or process control. -using System; -using System.Linq; -using System.Collections.Generic; -using System.Runtime.InteropServices; - -public static class HypertileDisplay { - [StructLayout(LayoutKind.Sequential)] public struct Luid { public uint low; public int high; } - [StructLayout(LayoutKind.Sequential)] public struct Rational { public uint numerator, denominator; } - [StructLayout(LayoutKind.Sequential)] public struct Source { public Luid adapter; public uint id, mode, flags; } - [StructLayout(LayoutKind.Sequential)] public struct Target { - public Luid adapter; public uint id, mode, technology, rotation, scaling; - public Rational refresh; public uint scanline; - [MarshalAs(UnmanagedType.Bool)] public bool available; - public uint flags; - } - [StructLayout(LayoutKind.Sequential)] public struct DisplayPath { public Source source; public Target target; public uint flags; } - [StructLayout(LayoutKind.Explicit, Size=64)] public struct Mode { - [FieldOffset(0)] public uint type; [FieldOffset(4)] public uint id; - [FieldOffset(8)] public Luid adapter; - [FieldOffset(16)] public ulong a; [FieldOffset(24)] public ulong b; - [FieldOffset(32)] public ulong c; [FieldOffset(40)] public ulong d; - [FieldOffset(48)] public ulong e; [FieldOffset(56)] public ulong f; - } - [StructLayout(LayoutKind.Sequential)] public struct Header { public uint type, size; public Luid adapter; public uint id; } - [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] public struct TargetName { - public Header header; public uint flags, technology; public ushort manufacturer, product; public uint connector; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst=64)] public string friendly; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string path; - } - [DllImport("user32.dll")] static extern int GetDisplayConfigBufferSizes(uint flags, out uint paths, out uint modes); - [DllImport("user32.dll")] static extern int QueryDisplayConfig(uint flags, ref uint np, [Out] DisplayPath[] paths, ref uint nm, [Out] Mode[] modes, IntPtr topology); - [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int DisplayConfigGetDeviceInfo(ref TargetName name); - [DllImport("user32.dll")] static extern int SetDisplayConfig(uint np, DisplayPath[] paths, uint nm, Mode[] modes, uint flags); - public class Display { public string id, name; public bool active, available, internalPanel, primary; public uint width, height; } - public class Snapshot { public string paths, modes; public string[] ids; } - class Config { public DisplayPath[] paths; public Mode[] modes; } - static void Check(int code, string op) { if(code!=0) throw new InvalidOperationException(op+": Windows error "+code); } - static Config Query(uint flags) { - for(int i=0;i<5;i++) { - uint np,nm; Check(GetDisplayConfigBufferSizes(flags,out np,out nm),"display inventory"); - var paths=new DisplayPath[np]; var modes=new Mode[nm]; - int code=QueryDisplayConfig(flags,ref np,paths,ref nm,modes,IntPtr.Zero); - if(code==122) continue; - Check(code,"display inventory"); - return new Config {paths=paths.Take((int)np).ToArray(), modes=modes.Take((int)nm).ToArray()}; - } - throw new InvalidOperationException("display inventory changed repeatedly"); - } - static TargetName Name(DisplayPath path) { - var name=new TargetName(); name.header.type=2; name.header.size=(uint)Marshal.SizeOf(typeof(TargetName)); - name.header.adapter=path.target.adapter; name.header.id=path.target.id; - Check(DisplayConfigGetDeviceInfo(ref name),"display identity"); return name; - } - public static Display[] Inspect() { - var c=Query(1); var result=new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach(var p in c.paths) { - var n=Name(p); if(String.IsNullOrEmpty(n.path)) continue; - bool active=(p.flags&1)!=0; - Display d; - if(!result.TryGetValue(n.path,out d)) { - d=new Display {id=n.path,name=n.friendly,available=p.target.available, - internalPanel=p.target.technology==0x80000000 || p.target.technology==6 || p.target.technology==11 || p.target.technology==13}; - result.Add(n.path,d); - } - d.available|=p.target.available; d.active|=active; - if(active && p.source.mode>32); d.primary=(int)(m.b>>32)==0 && (int)m.c==0; } - } - } - return result.Values.ToArray(); - } - static string Pack(T[] values) { - int size=Marshal.SizeOf(typeof(T)); byte[] data=new byte[checked(size*values.Length)]; IntPtr p=Marshal.AllocHGlobal(size); - try { for(int i=0;i(string value) { - byte[] data=Convert.FromBase64String(value); int size=Marshal.SizeOf(typeof(T)); - if(data.Length%size!=0 || data.Length>1048576) throw new InvalidOperationException("invalid display snapshot"); - T[] result=new T[data.Length/size]; IntPtr p=Marshal.AllocHGlobal(size); - try {for(int i=0;iName(p).path).Distinct(StringComparer.OrdinalIgnoreCase).ToArray()}; - } - public static void Restore(string paths, string modes) { - var ps=Unpack(paths); var ms=Unpack(modes); - if(ps.Length==0) throw new InvalidOperationException("empty physical display snapshot"); - Check(SetDisplayConfig((uint)ps.Length,ps,(uint)ms.Length,ms,0x60),"validate physical displays"); - Check(SetDisplayConfig((uint)ps.Length,ps,(uint)ms.Length,ms,0x2a0),"restore physical displays"); - } - public static void Only(string id, bool persist) { - var c=Query(1); - var matches=c.paths.Where(p=>p.target.available && String.Equals(Name(p).path,id,StringComparison.OrdinalIgnoreCase)).ToArray(); - if(matches.Length==0) throw new InvalidOperationException("display unavailable"); - // Prefer the currently active path, which has a source known to work. - var path=matches.OrderByDescending(p=>(p.flags&1)!=0).First(); - path.flags=1;path.source.mode=UInt32.MaxValue;path.target.mode=UInt32.MaxValue; - var ps=new[]{path}; - // Use the target's saved mode first. If that topology has never existed, - // let Windows choose a supported mode. Preparation does not persist it. - int code=SetDisplayConfig(1,ps,0,null,0x50); - if(code==0 && !persist) { Check(SetDisplayConfig(1,ps,0,null,0x90),"activate stream display"); return; } - Check(SetDisplayConfig(1,ps,0,null,0x460),"validate display"); - Check(SetDisplayConfig(1,ps,0,null,persist ? 0x6a0u : 0x4a0u),"activate display"); - } - public static void Remove(string id) { - var c=Query(2); var ps=c.paths.Where(p=>!String.Equals(Name(p).path,id,StringComparison.OrdinalIgnoreCase)).ToArray(); - if(ps.Length==0) throw new InvalidOperationException("no physical display available"); - Check(SetDisplayConfig((uint)ps.Length,ps,(uint)c.modes.Length,c.modes,0x60),"validate physical displays"); - Check(SetDisplayConfig((uint)ps.Length,ps,(uint)c.modes.Length,c.modes,0x2a0),"deactivate stream display"); - } -} diff --git a/stream/windows/Guard.ps1 b/stream/windows/Guard.ps1 deleted file mode 100644 index 914c230..0000000 --- a/stream/windows/Guard.ps1 +++ /dev/null @@ -1,143 +0,0 @@ -# Runs only in the configured user's console session. Fixed display operations; -# requests cannot contain code, paths, process names, or arbitrary commands. -$ErrorActionPreference='Stop' -$ProgressPreference='SilentlyContinue' -$root=$PSScriptRoot -. (Join-Path $root 'Policy.ps1') -Add-Type -Path (Join-Path $root 'Display.cs') -$config=Get-Content (Join-Path $root 'config.json') -Raw | ConvertFrom-Json -$mutex=[Threading.Mutex]::new($false,'Local\HypertileDisplayGuard') -try {$locked=$mutex.WaitOne(0)} catch [Threading.AbandonedMutexException] {$locked=$true} -if (-not $locked) {exit 0} -function Write-Json($Path,$Value) { - $tmp=$Path+'.'+[guid]::NewGuid().ToString('N')+'.tmp' - [IO.File]::WriteAllText($tmp,($Value | ConvertTo-Json -Depth 16),[Text.UTF8Encoding]::new($false)) - if (Test-Path $Path) {[IO.File]::Replace($tmp,$Path,[NullString]::Value)} else {[IO.File]::Move($tmp,$Path)} -} -$statePath=Join-Path $root 'state.json' -$state=@{version=1;owner='';sequence=0;phase='idle';baseline=$null;retired=@();ever_owned=$false;deadline=0;error=$null} -if (Test-Path $statePath) { - $saved=Get-Content $statePath -Raw | ConvertFrom-Json - foreach($p in $saved.PSObject.Properties) {$state[$p.Name]=$p.Value} -} -function Save-State {Write-Json $statePath $state} -function Now {return [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()} -function Assert-Identity { - $sunshine=Get-Content (Join-Path $config.sunshine_config 'sunshine_state.json') -Raw | ConvertFrom-Json - if ($sunshine.root.uniqueid -ine $config.pairing_uuid) {throw 'host-identity-mismatch'} - $conf=Get-Content (Join-Path $config.sunshine_config 'sunshine.conf') -Raw - if ($conf -notmatch '(?m)^dd_configuration_option\s*=\s*disabled\s*$') {throw 'display-owner-conflict: Sunshine display automation must remain disabled'} - $output=[regex]::Matches($conf,'(?m)^output_name\s*=\s*([^\r\n]+)') - if ($output.Count -ne 1 -or $output[0].Groups[1].Value.Trim() -ine $config.output_uuid) {throw 'capture-display-changed'} - if ($conf -match '(?m)^min_log_level\s*=\s*(warning|error|fatal|none)') {throw 'stream-observation-unavailable: Sunshine must log connection events'} -} -function Get-Activity { - if (-not (Get-Process sunshine -ErrorAction SilentlyContinue | Where-Object {$_.SessionId -eq (Get-Process -Id $PID).SessionId})) {return 0} - # Unknown/truncated logs block recovery rather than guessing idle. - return Get-SunshineActivity @(Get-Content (Join-Path $config.sunshine_config 'sunshine.log') -Tail 12000) (Now) -} -function View($Devices) { - return @{version=1;owner=$state.owner;sequence=$state.sequence;phase=$state.phase;error=$state.error; - active=@($Devices | Where-Object {$_.active});available=@($Devices | Where-Object {$_.available}); - capture_id=$config.capture_id;pairing_uuid=$config.pairing_uuid;observed_at=(Now)} -} -function Recover { - $devices=@([HypertileDisplay]::Inspect()) - $ids=@(); if ($state.baseline) {$ids=@($state.baseline.ids | Where-Object {$_ -ine $config.capture_id})} - $choice=Get-RecoveryChoice $devices $config.capture_id $ids - if ($choice.action -eq 'pending') {$state.phase='restore-pending';$state.error='physical-display-unavailable: open the lid or attach a monitor';return} - $captureActive=@($devices | Where-Object {$_.active -and $_.id -ieq $config.capture_id}).Count -gt 0 - if ($choice.action -eq 'keep-physical') { - if ($captureActive) {[HypertileDisplay]::Remove($config.capture_id)} - } elseif ($choice.action -eq 'baseline') { - try {[HypertileDisplay]::Restore($state.baseline.paths,$state.baseline.modes)} - catch { - # Dock changes can invalidate adapter/source IDs despite the same - # monitor identity. Recover one verified physical output in that case. - $fallback=@($devices | Where-Object {$_.available -and $_.id -ine $config.capture_id} | Sort-Object internalPanel -Descending)[0] - [HypertileDisplay]::Only($fallback.id,$true) - } - } else {[HypertileDisplay]::Only($choice.ids[0],$true)} - $after=@([HypertileDisplay]::Inspect() | Where-Object {$_.active}) - if ($after.Count -eq 0 -or @($after | Where-Object {$_.id -ieq $config.capture_id}).Count -gt 0) {throw 'restore-readback-failed'} - $state.phase='idle';$state.error=$null -} -function Handle($Request) { - if ($Request.version -ne 1 -or $Request.pairing_uuid -ine $config.pairing_uuid -or $Request.capture_id -ine $config.capture_id) {throw 'host-identity-mismatch'} - if ($Request.expires -lt (Now) -or $Request.expires -gt ((Now)+90)) {throw 'request-expired'} - if ($Request.operation -notin @('probe','prepare','restore','status')) {throw 'unsupported-operation'} - Assert-Identity - if ($Request.operation -in @('probe','status')) {return (View @([HypertileDisplay]::Inspect()))} - if ($Request.owner -cnotmatch '^[0-9a-f]{32}$' -or $Request.sequence -isnot [int] -or $Request.sequence -lt 1) {throw 'invalid-operation-token'} - $state.retired=@($state.retired | Where-Object {$_.expires -gt (Now)}) - if ($Request.operation -eq 'prepare' -and $Request.owner -in @($state.retired.owner)) {throw 'operation-cancelled'} - if ($state.owner -eq $Request.owner -and $Request.sequence -le $state.sequence) { - if ($Request.sequence -eq $state.sequence) {return (View @([HypertileDisplay]::Inspect()))} - throw 'stale-operation' - } - if ($Request.operation -eq 'restore') { - if ($state.owner -ne $Request.owner -and $state.phase -ne 'idle') {throw 'display-owned-by-another-session'} - # Tombstone before restoring; a delayed preparation cannot resurrect it. - $state.retired=@($state.retired | Where-Object {$_.owner -ne $Request.owner})+@(@{owner=$Request.owner;expires=(Now)+120}) - if ($state.owner -ne $Request.owner -and $state.phase -eq 'idle') {Save-State;return (View @([HypertileDisplay]::Inspect()))} - if ($state.owner -eq $Request.owner) {$state.sequence=$Request.sequence} - $state.phase='restore-pending';$state.deadline=(Now)+2;Save-State - } else { - if ($state.owner -ne $Request.owner -and $state.phase -ne 'idle') {throw 'restore-pending: finish the previous session first'} - $active=Get-Activity - if ($null -eq $active) {throw 'stream-observation-unavailable'} - if ($active -gt 0 -and $state.owner -ne $Request.owner) {throw 'another-stream-is-active'} - if ($state.owner -ne $Request.owner) { - $baseline=[HypertileDisplay]::Capture() - if ($baseline.ids -contains $config.capture_id) {throw 'unmanaged-virtual-display: recover physical displays before connecting'} - $state.baseline=$baseline - } - $state.owner=$Request.owner;$state.sequence=$Request.sequence;$state.phase='preparing';$state.deadline=(Now)+45;$state.error=$null;$state.ever_owned=$true - Save-State - [HypertileDisplay]::Only($config.capture_id,$false) - $after=@([HypertileDisplay]::Inspect() | Where-Object {$_.active}) - if ($after.Count -ne 1 -or $after[0].id -ine $config.capture_id) {throw 'display-readback-failed'} - Save-State - } - return (View @([HypertileDisplay]::Inspect())) -} -try { - while($true) { - $changed=$state | ConvertTo-Json -Depth 16 -Compress - foreach($file in @(Get-ChildItem (Join-Path $root 'requests') -Filter '*.json' | Sort-Object Name)) { - $reply=Join-Path $root ('responses\'+$file.Name) - try { - if ($file.Length -gt 8192) {throw 'request-too-large'} - $request=Get-Content $file.FullName -Raw | ConvertFrom-Json - $result=Handle $request - Write-Json $reply @{ok=$true;result=$result} - } catch {Write-Json $reply @{ok=$false;error=$_.Exception.Message}} - Remove-Item -LiteralPath $file.FullName - } - try { - Assert-Identity - $activity=Get-Activity - $devices=@([HypertileDisplay]::Inspect()) - if ($null -eq $activity) {$state.error='stream-observation-unavailable: cannot confirm Sunshine is idle'} - if ($activity -gt 0) { - if ($state.phase -in @('preparing','streaming')) {$state.phase='streaming';$state.deadline=(Now)+15} - # Never restore during any active stream, including one not ours. - } elseif ($null -ne $activity -and $state.ever_owned -and (Now) -ge $state.deadline) { - $virtualActive=@($devices | Where-Object {$_.active -and $_.id -ieq $config.capture_id}).Count -gt 0 - if ($state.phase -ne 'idle' -or $virtualActive) {Recover} - } - if ($state.phase -eq 'streaming') { - $active=@($devices | Where-Object {$_.active}) - if ($active.Count -ne 1 -or $active[0].id -ine $config.capture_id) {$state.error='display-conflict: display layout changed during streaming'} else {$state.error=$null} - } - Write-Json (Join-Path $root 'status.json') (View @([HypertileDisplay]::Inspect())) - } catch { - $state.error=$_.Exception.Message - if ($state.phase -notin @('idle','streaming','preparing')) {$state.phase='restore-pending'} - Write-Json (Join-Path $root 'status.json') @{version=1;phase=$state.phase;owner=$state.owner;error=$state.error;observed_at=(Now);pairing_uuid=$config.pairing_uuid;capture_id=$config.capture_id} - } - if (($state | ConvertTo-Json -Depth 16 -Compress) -ne $changed) {Save-State} - Get-ChildItem (Join-Path $root 'responses') -Filter '*.json' | Sort-Object LastWriteTime -Descending | Select-Object -Skip 64 | Remove-Item - Start-Sleep -Seconds 2 - } -} finally {$mutex.ReleaseMutex();$mutex.Dispose()} diff --git a/stream/windows/Install.ps1 b/stream/windows/Install.ps1 deleted file mode 100644 index f79fb04..0000000 --- a/stream/windows/Install.ps1 +++ /dev/null @@ -1,88 +0,0 @@ -$ErrorActionPreference='Stop' -$ProgressPreference='SilentlyContinue' -$root='C:\ProgramData\Hypertile\display' -$task='Hypertile Display Recovery' -$identity=[Security.Principal.WindowsIdentity]::GetCurrent() -if (-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {throw 'Administrator setup is required'} -if (Test-Path (Join-Path $root 'config.json')) {throw 'Display helper is already installed; stop and review before upgrading'} -$sunshineRoot=Join-Path $env:ProgramFiles 'Sunshine\config' -$sunshineState=Get-Content (Join-Path $sunshineRoot 'sunshine_state.json') -Raw | ConvertFrom-Json -if ($sunshineState.root.uniqueid -ine $package.pairing_uuid) {throw 'Paired Sunshine identity mismatch'} -$confPath=Join-Path $sunshineRoot 'sunshine.conf' -$original=[IO.File]::ReadAllText($confPath) -$output=[regex]::Matches($original,'(?m)^output_name\s*=\s*([^\r\n]+)') -if ($output.Count -ne 1 -or $output[0].Groups[1].Value.Trim() -ine $package.output_uuid) {throw 'Sunshine capture output changed; stopped before editing'} -$null=New-Item -ItemType Directory -Path $root -Force -& icacls.exe $root /inheritance:r /grant:r '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' ('*'+$identity.User.Value+':(OI)(CI)M') | Out-Null -if ($LASTEXITCODE -ne 0) {throw 'Could not protect helper files'} -foreach($name in @('requests','responses')) {$null=New-Item -ItemType Directory -Path (Join-Path $root $name) -Force} -foreach($file in $package.files.PSObject.Properties) { - if ($file.Name -notin @('Guard.ps1','Policy.ps1','Display.cs','Test.ps1')) {throw 'Unexpected package file'} - [IO.File]::WriteAllBytes((Join-Path $root $file.Name),[Convert]::FromBase64String($file.Value)) -} -. (Join-Path $root 'Policy.ps1') -function Assert-NoStream { - $activity=Get-SunshineActivity @(Get-Content (Join-Path $sunshineRoot 'sunshine.log') -Tail 12000) ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds()) - if ($null -eq $activity) {throw 'Cannot confirm Sunshine is idle; stopped before changing its configuration'} - if ($activity -gt 0) {throw 'Disconnect every stream before installing display recovery'} -} -Assert-NoStream -$principal=New-ScheduledTaskPrincipal -UserId $identity.Name -LogonType Interactive -RunLevel Limited -$settings=New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -$probeTask='Hypertile Display Probe '+[guid]::NewGuid().ToString('N') -$inventoryPath=Join-Path $root ('inventory-'+[guid]::NewGuid().ToString('N')+'.json') -$probeCode="try { & '$root\Test.ps1' | Set-Content '$inventoryPath' -Encoding utf8 } catch { @{error=`$_.Exception.Message} | ConvertTo-Json | Set-Content '$inventoryPath' -Encoding utf8 }" -$encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($probeCode)) -$action=New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -Argument ('-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand '+$encoded) -try { - $null=Register-ScheduledTask -TaskName $probeTask -Action $action -Principal $principal -Settings $settings - Start-ScheduledTask -TaskName $probeTask - $until=(Get-Date).AddSeconds(25) - while (-not (Test-Path $inventoryPath) -and (Get-Date) -lt $until) {Start-Sleep -Milliseconds 300} - if (-not (Test-Path $inventoryPath)) {throw 'Console display probe did not run'} - $inventory=Get-Content $inventoryPath -Raw | ConvertFrom-Json - if ($inventory.error) {throw $inventory.error} -} finally {Stop-ScheduledTask -TaskName $probeTask -ErrorAction SilentlyContinue;Unregister-ScheduledTask -TaskName $probeTask -Confirm:$false -ErrorAction SilentlyContinue} -$capture=@($inventory.devices | Where-Object {$_.id -match ('(?i)#'+[regex]::Escape($package.capture_hardware)+'#')}) -if ($capture.Count -ne 1) {throw 'Expected one virtual capture display; stopped before changing Sunshine'} -if ($capture[0].active) {throw 'Restore the physical desktop before installing display recovery'} -$config=@{version=1;pairing_uuid=$package.pairing_uuid;output_uuid=$package.output_uuid;capture_id=$capture[0].id;sunshine_config=$sunshineRoot;account=$identity.Name} -$backup=Join-Path $root 'sunshine.conf.before' -[IO.File]::WriteAllText($backup,$original,[Text.UTF8Encoding]::new($false)) -$pattern='(?m)^dd_configuration_option\s*=[^\r\n]*' -if ([regex]::Matches($original,$pattern).Count -ne 1) {throw 'Expected one Sunshine display option'} -$updated=[regex]::Replace($original,$pattern,'dd_configuration_option = disabled') -$written=$false -try { - Assert-NoStream - if ([IO.File]::ReadAllText($confPath) -cne $original) {throw 'Sunshine configuration changed during setup'} - $config | ConvertTo-Json | Set-Content (Join-Path $root 'config.json') -Encoding utf8 - [IO.File]::WriteAllText($confPath,$updated,[Text.UTF8Encoding]::new($false));$written=$true - Restart-Service SunshineService - (Get-Service SunshineService).WaitForStatus('Running',[TimeSpan]::FromSeconds(15)) - $guardCode="try { & '$root\Guard.ps1' } catch { @{error=`$_.Exception.Message;line=`$_.InvocationInfo.ScriptLineNumber} | ConvertTo-Json | Set-Content '$root\fatal.json' -Encoding utf8; exit 1 }" - $guardEncoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($guardCode)) - $action=New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -Argument ('-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand '+$guardEncoded) - $trigger=New-ScheduledTaskTrigger -AtLogOn -User $identity.Name - $settings=New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) - $null=Register-ScheduledTask -TaskName $task -Action $action -Principal $principal -Trigger $trigger -Settings $settings - $started=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - Start-ScheduledTask -TaskName $task - $until=(Get-Date).AddSeconds(25) - while (-not (Test-Path (Join-Path $root 'status.json')) -and (Get-Date) -lt $until) {Start-Sleep -Milliseconds 300} - if (-not (Test-Path (Join-Path $root 'status.json'))) {throw 'Display helper did not start'} - $status=Get-Content (Join-Path $root 'status.json') -Raw | ConvertFrom-Json - if ($status.error) {throw $status.error} - # Require another successful write: startup alone does not prove the loop lives. - Start-Sleep -Seconds 5 - $status=Get-Content (Join-Path $root 'status.json') -Raw | ConvertFrom-Json - if ($status.error -or $status.observed_at -le $started -or [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()-$status.observed_at -gt 4) {throw 'Display helper did not remain healthy'} - @{ok=$true;installed=$root;task=$task;tests_passed=$inventory.tests_passed;device_id=$capture[0].id;status=$status} | ConvertTo-Json -Depth 12 -Compress -} catch { - $failure=$_ - Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue - Unregister-ScheduledTask -TaskName $task -Confirm:$false -ErrorAction SilentlyContinue - if ($written -and [IO.File]::ReadAllText($confPath) -ceq $updated) {[IO.File]::WriteAllText($confPath,$original,[Text.UTF8Encoding]::new($false));Restart-Service SunshineService} - Remove-Item (Join-Path $root 'config.json') -ErrorAction SilentlyContinue - throw $failure -} diff --git a/stream/windows/Policy.ps1 b/stream/windows/Policy.ps1 deleted file mode 100644 index 2423d8d..0000000 --- a/stream/windows/Policy.ps1 +++ /dev/null @@ -1,44 +0,0 @@ -# Pure selection policy, also exercised without changing real displays. -function Get-RecoveryChoice($Devices, $CaptureId, $BaselineIds) { - $physical=@($Devices | Where-Object {$_.id -ine $CaptureId -and $_.available}) - $active=@($physical | Where-Object {$_.active}) - if ($active.Count -gt 0) { return @{action='keep-physical'; ids=@($active.id)} } - if ($BaselineIds.Count -gt 0 -and @($BaselineIds | Where-Object {$_ -notin $physical.id}).Count -eq 0) { - return @{action='baseline'; ids=@($BaselineIds)} - } - $internal=@($physical | Where-Object {$_.internalPanel}) - if ($internal.Count -gt 0) { return @{action='single'; ids=@($internal[0].id)} } - if ($physical.Count -gt 0) { return @{action='single'; ids=@($physical[0].id)} } - return @{action='pending'; ids=@()} -} - -function Get-SunshineActivity($Lines, $NowSeconds) { - $count=$null; $pending=@() - foreach($line in $Lines) { - if ($line -match 'Sunshine version:') {$count=0;$pending=@()} - if ($line -match 'New streaming session started \[active sessions: (\d+)\]') { - $slots=[int]$Matches[1] - # Session slots can still include a disconnected client whose worker - # is shutting down. Count actual connection events, not those slots. - if ($null -eq $count -and $slots -eq 1) {$count=0} - if ($line -match '^\[([^\]]+)\]') { - try { - $date=[DateTime]::ParseExact($Matches[1],'yyyy-MM-dd HH:mm:ss.fff',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::AssumeLocal) - $pending+=([DateTimeOffset]$date).ToUnixTimeSeconds()+45 - } catch {return $null} - } else {return $null} - } - if ($line -match 'CLIENT CONNECTED') { - if ($null -ne $count) {$count++} - if ($pending.Count) {$pending=@($pending | Select-Object -Skip 1)} - } - if ($line -match 'CLIENT DISCONNECTED' -and $null -ne $count) {$count=[Math]::Max(0,$count-1)} - if ($line -match ': Initial Ping Timeout') { - if ($pending.Count) {$pending=@($pending | Select-Object -Skip 1)} else {$count=$null} - } elseif ($line -match ': Ping Timeout' -and $null -ne $count) { - if ($count -eq 1) {$count=0} elseif ($count -gt 1) {$count=$null} - } - } - if ($null -eq $count) {return $null} - return $count+@($pending | Where-Object {$_ -gt $NowSeconds}).Count -} diff --git a/stream/windows/Test.ps1 b/stream/windows/Test.ps1 deleted file mode 100644 index 257248a..0000000 --- a/stream/windows/Test.ps1 +++ /dev/null @@ -1,55 +0,0 @@ -param([switch]$PolicyOnly) -$ErrorActionPreference='Stop' -. (Join-Path $PSScriptRoot 'Policy.ps1') -function Device($id,$active,$available,$internal) {return @{id=$id;active=$active;available=$available;internalPanel=$internal}} -$v=Device 'virtual' $true $true $false -$i=Device 'internal' $false $true $true -$d=Device 'dock' $false $true $false -$cases=@( - @{devices=@($v,$i);baseline=@('dock');action='single';id='internal'}, - @{devices=@($v);baseline=@('dock');action='pending';id=$null}, - @{devices=@($v,(Device 'internal' $false $false $true));baseline=@('dock');action='pending';id=$null}, - @{devices=@($v,$d);baseline=@('dock');action='baseline';id='dock'}, - @{devices=@($v,$i,$d);baseline=@('dock','internal');action='baseline';id='dock'}, - @{devices=@($v,(Device 'dock' $true $true $false),$i);baseline=@('internal');action='keep-physical';id='dock'}, - @{devices=@($v,$d);baseline=@('missing');action='single';id='dock'}, - @{devices=@($v,$i,$d);baseline=@();action='single';id='internal'}, - @{devices=@((Device 'internal' $true $true $true));baseline=@('dock');action='keep-physical';id='internal'} -) -foreach($case in $cases) { - $choice=Get-RecoveryChoice $case.devices 'virtual' $case.baseline - if ($choice.action -ne $case.action -or ($case.id -and $choice.ids[0] -ne $case.id)) {throw ('Recovery policy failed: '+($case|ConvertTo-Json -Compress -Depth 8))} -} -$stamp=Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff' -$now=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds() -$start="[$stamp]: Info: New streaming session started [active sessions: 1]" -$stale="[$stamp]: Info: New streaming session started [active sessions: 2]" -$activityCases=@( - @{lines=@('Sunshine version: test');expected=0}, - @{lines=@('Sunshine version: test',$start,'CLIENT CONNECTED');expected=1}, - @{lines=@('Sunshine version: test',$start);expected=1}, - @{lines=@('Sunshine version: test',$start,'CLIENT CONNECTED','CLIENT DISCONNECTED');expected=0}, - @{lines=@('Sunshine version: test',$start,'CLIENT CONNECTED','CLIENT DISCONNECTED',$stale,'CLIENT CONNECTED','CLIENT DISCONNECTED');expected=0}, - @{lines=@('Sunshine version: test',$start,'CLIENT CONNECTED',$stale,'CLIENT CONNECTED','CLIENT DISCONNECTED');expected=1}, - @{lines=@('Sunshine version: test',$start,'CLIENT CONNECTED','host: Ping Timeout');expected=0}, - @{lines=@('unrelated log after truncation');expected=$null} -) -foreach($case in $activityCases) { - $value=Get-SunshineActivity $case.lines $now - if ($value -ne $case.expected) {throw ('Connection observation failed: '+($case|ConvertTo-Json -Compress))} -} -Add-Type -Path (Join-Path $PSScriptRoot 'Display.cs') -$tokens=$null;$errors=$null -$ast=[Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'Guard.ps1'),[ref]$tokens,[ref]$errors) -if ($errors.Count) {throw $errors[0].Message} -$writer=$ast.Find({param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Write-Json'},$true) -. ([ScriptBlock]::Create($writer.Extent.Text)) -$testPath=Join-Path $PSScriptRoot 'atomic-test.json' -try { - Write-Json $testPath @{value=1} - Write-Json $testPath @{value=2} - if ((Get-Content $testPath -Raw | ConvertFrom-Json).value -ne 2) {throw 'Atomic journal replacement failed'} -} finally {Remove-Item $testPath -ErrorAction SilentlyContinue} -if ([Runtime.InteropServices.Marshal]::SizeOf([type][HypertileDisplay+DisplayPath]) -ne 72 -or [Runtime.InteropServices.Marshal]::SizeOf([type][HypertileDisplay+Mode]) -ne 64) {throw 'Native display layout mismatch'} -if ($PolicyOnly) {@{tests_passed=($cases.Count+$activityCases.Count+1)} | ConvertTo-Json;exit 0} -@{tests_passed=($cases.Count+$activityCases.Count+1);devices=@([HypertileDisplay]::Inspect());baseline=[HypertileDisplay]::Capture()} | ConvertTo-Json -Depth 12 diff --git a/stream/windows_display.py b/stream/windows_display.py deleted file mode 100644 index 5ce59c6..0000000 --- a/stream/windows_display.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Typed transport to the Windows console-session display helper. - -SSH only submits bounded JSON operations to a private mailbox. The installed -helper owns display writes, journals and offline recovery on the laptop. -""" -import base64 -import json -from pathlib import Path -import re -import subprocess -import tempfile -import time -import uuid - -ROOT = r"C:\ProgramData\Hypertile\display" -ALIAS = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\Z") - - -def powershell(alias, script, timeout=35): - if not ALIAS.fullmatch(alias): - raise ValueError("invalid Windows SSH alias") - if len(script.encode("utf-8")) > 4096: - # Windows OpenSSH can leave large stdin submissions waiting for EOF. - # Stage installation packages with SFTP; runtime requests stay small. - stage = "C:/ProgramData/Hypertile/setup-" + uuid.uuid4().hex - powershell(alias, "$ErrorActionPreference='Stop'; $p='" + stage + "'; New-Item -ItemType Directory -Path $p -Force | Out-Null; " - "icacls.exe $p /inheritance:r /grant:r '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' | Out-Null; " - "if ($LASTEXITCODE -ne 0) {throw 'Cannot protect installation package'}; @{ok=$true} | ConvertTo-Json -Compress") - try: - with tempfile.NamedTemporaryFile(suffix=".ps1") as source: - source.write(script.encode("utf-8")); source.flush() - p = subprocess.run(["scp", "-q", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", - source.name, alias + ":" + stage + "/Install.ps1"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30) - if p.returncode: - raise ValueError("Windows display package transfer failed") - return powershell(alias, "& '" + stage + "/Install.ps1'", timeout=timeout) - finally: - powershell(alias, "Remove-Item -LiteralPath '" + stage + "' -Recurse -Force; @{ok=$true} | ConvertTo-Json -Compress") - # stdin avoids Windows cmd.exe's command-line length limit. No password, - # interpolated shell command or machine-specific key is stored in a scene. - launcher = "& ([ScriptBlock]::Create([Console]::In.ReadToEnd()))" - encoded = base64.b64encode(launcher.encode("utf-16le")).decode() - p = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", - "-o", "ConnectTimeout=5", "-o", "LogLevel=ERROR", alias, - "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], - input=script, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) - if p.returncode: - raise ValueError("host-unreachable: Windows SSH/helper unavailable") - try: - return json.loads(p.stdout.lstrip("\ufeff")) - except ValueError: - raise ValueError("display-probe-failed: invalid Windows helper reply") from None - - -def remote(computer, display, operation, **values): - if operation not in ("probe", "status", "prepare", "restore"): - raise ValueError("unsupported Windows display operation") - request = {"version": 1, "operation": operation, "pairing_uuid": computer["pairing_uuid"], - "capture_id": display["device_id"], "expires": int(time.time()) + 60, **values} - payload = base64.b64encode(json.dumps(request).encode()).decode() - script = r"""$ErrorActionPreference='Stop'; $ProgressPreference='SilentlyContinue' -$root='C:\ProgramData\Hypertile\display' -try { - $status=Get-Content (Join-Path $root 'status.json') -Raw | ConvertFrom-Json - if ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds()-$status.observed_at -gt 10) {throw 'display-helper-unavailable: console helper stopped'} - $request=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__PAYLOAD__')) | ConvertFrom-Json - if ($request.pairing_uuid -ine $status.pairing_uuid -or $request.capture_id -ine $status.capture_id) {throw 'host-identity-mismatch'} - if ($request.operation -in @('status','probe')) { - @{ok=$true;result=$status} | ConvertTo-Json -Depth 12 -Compress - } else { - $id=[guid]::NewGuid().ToString('N')+'.json' - $path=Join-Path $root ('requests\'+$id) - $tmp=$path+'.tmp' - [IO.File]::WriteAllText($tmp,($request | ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) - [IO.File]::Move($tmp,$path) - $response=Join-Path $root ('responses\'+$id) - $until=(Get-Date).AddSeconds(25) - while (-not (Test-Path $response) -and (Get-Date) -lt $until) {Start-Sleep -Milliseconds 200} - if (-not (Test-Path $response)) {throw 'display-helper-timeout: operation may still finish; restoration is required'} - Get-Content $response -Raw - Remove-Item $response - } -} catch {@{ok=$false;error=$_.Exception.Message} | ConvertTo-Json -Compress} -""".replace("__PAYLOAD__", payload) - response = powershell(computer["ssh"]["alias"], script) - if not response.get("ok"): - raise ValueError(response.get("error", "Windows display operation failed")) - result = response["result"] - if result.get("pairing_uuid", "").lower() != computer["pairing_uuid"].lower() or result.get("capture_id", "").lower() != display["device_id"].lower(): - raise ValueError("host-identity-mismatch") - return result - - -def prepare(record, host, persist): - journal = record.setdefault("journal", {}) - entry = journal.setdefault("windows", {"owner": uuid.uuid4().hex, "sequence": 0, "phase": "intent"}) - entry["sequence"] += 1 - persist() # Includes the owner even if SSH times out after the host write. - result = host.remote("prepare", owner=entry["owner"], sequence=entry["sequence"]) - active = result.get("active", []) - if result.get("owner") != entry["owner"] or result.get("phase") not in ("preparing", "streaming") or len(active) != 1 or active[0]["id"].lower() != host.display["device_id"].lower(): - raise ValueError("display-readback-failed: Windows recovery required") - entry["phase"] = "applied" - record["resolved"] = {**record.get("resolved", {}), "restoration": "managed", "display": result} - persist() - - -def restore(record, host, persist): - entry = record.get("journal", {}).get("windows") - if not entry: - return True - if entry["phase"] != "restoring": - entry["sequence"] += 1 - # Persist sequence first, but keep retrying the same restore after a lost - # acknowledgement. A status read alone cannot cancel a late preparation. - persist() - result = host.remote("restore", owner=entry["owner"], sequence=entry["sequence"]) - entry["phase"] = "restoring" - persist() - else: - result = host.remote("status") - record["resolved"] = {**record.get("resolved", {}), "display": result} - active = result.get("active", []) - physical = active and all(d["id"].lower() != host.display["device_id"].lower() for d in active) - if result.get("phase") == "idle" and not result.get("error") and physical: - del record["journal"]["windows"] - persist() - return True - return False - - -if __name__ == "__main__": - import argparse - parser = argparse.ArgumentParser(description="Install the narrowly scoped Windows display helper over approved SSH") - parser.add_argument("--ssh", required=True) - parser.add_argument("--pairing-uuid", required=True) - parser.add_argument("--output-uuid", required=True) - parser.add_argument("--capture-hardware", required=True, help="Exact EDID hardware ID, for example MTT1337") - args = parser.parse_args() - for value in (args.pairing_uuid, args.output_uuid.strip("{}")): - if not re.fullmatch(r"[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}", value): - parser.error("invalid Sunshine UUID") - if not re.fullmatch(r"[A-Z0-9]{7}", args.capture_hardware): - parser.error("invalid EDID hardware ID") - folder = Path(__file__).with_name("windows") - package = {"files": {p.name: base64.b64encode(p.read_bytes()).decode() for p in folder.iterdir() - if p.name in ("Guard.ps1", "Policy.ps1", "Display.cs", "Test.ps1")}, - "pairing_uuid": args.pairing_uuid, "output_uuid": "{" + args.output_uuid.strip("{}") + "}", - "capture_hardware": args.capture_hardware} - payload = base64.b64encode(json.dumps(package).encode()).decode() - script = "$package=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + payload + "')) | ConvertFrom-Json\n" - script += "try {\n" + (folder / "Install.ps1").read_text() + "\n} catch { @{ok=$false;error=$_.Exception.Message} | ConvertTo-Json -Compress }\n" - print(json.dumps(powershell(args.ssh, script, timeout=90), indent=2)) diff --git a/test/apps.py b/test/apps.py index a44962e..44484e1 100644 --- a/test/apps.py +++ b/test/apps.py @@ -18,7 +18,7 @@ from scene_service import SceneController from service import Launchers, match_windows from ipc import daemon, request -import streams +import scene_recovery class Desktop(DesktopApps): @@ -113,7 +113,7 @@ def test_installed_entry_supplies_exact_identity(self): 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.assertNotIn("computers", self.command("catalog")) self.assertFalse(self.desktop.launched) def test_standalone_service_does_not_take_stream_lock_or_read_computers(self): @@ -123,7 +123,7 @@ def test_standalone_service_does_not_take_stream_lock_or_read_computers(self): with (legacy / "writer.lock").open("a") as lock: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) ctl = self.controller() - self.assertFalse(ctl.records) + self.assertFalse(ctl.scenes.records) ctl.tick() self.assertEqual((legacy / "state.json").read_text(), "not a scene journal") @@ -223,11 +223,11 @@ def fail(method, args): def test_session_checkpoint_uses_one_launcher_and_preserves_manual_departure(self): window = self.window() self.start() - captured = streams.capture(self.comp.snapshot()) + captured = scene_recovery.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()) + captured = scene_recovery.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) @@ -245,7 +245,7 @@ def test_scene_preview_pauses_capture_without_any_stream_journal(self): self.ctl.state["browse"]["active"]["1"] = {"token": "preview"} self.ctl.persist() with self.assertRaisesRegex(ValueError, "layout preview"): - streams.capture(self.comp.snapshot()) + scene_recovery.capture(self.comp.snapshot()) def test_missing_empty_workspace_can_return_with_the_app(self): self.start() @@ -288,7 +288,7 @@ def test_extra_matching_windows_remain_in_normal_checkpoint(self): self.window() self.start() self.window(address="extra") - captured = streams.capture(self.comp.snapshot()) + captured = scene_recovery.capture(self.comp.snapshot()) self.assertEqual([w["address"] for w in captured["windows"]], ["extra"]) self.assertIn("z-right", captured["scenes"][0]["document"]["sources"]) @@ -360,12 +360,6 @@ def test_launch_uses_desktop_file_without_shell(self): 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__": diff --git a/test/browse.py b/test/browse.py index a8af7c2..b1d5edd 100644 --- a/test/browse.py +++ b/test/browse.py @@ -1,4 +1,4 @@ -"""Managed layout previews exercise real scene/stream state transitions.""" +"""Managed layout previews exercise real scene state transitions.""" import copy import importlib.util import json @@ -26,25 +26,18 @@ def start(self, token="overlay", name="other"): self.layouts.entries.append({"name": "other", "spec": {"columns": [{"name": "one"}, {"name": "two"}]}}) return self.command("browse", name=name, browse_token=token) - def test_preview_and_cancel_keep_stream_scene_and_saved_files(self): + def test_preview_and_cancel_keep_scene_and_saved_files(self): self.ready() - before = copy.deepcopy(self.ctl.records["laptop"]) scene = self.command("current") saved = (self.root / "scenes/work.json").read_bytes() self.start() self.assertEqual(self.comp.desktop["workspaces"][0]["layout"], "lua:other") - self.comp.invalid = True # Preview zones do not contain the assigned leaf. self.tick(4) - self.assertEqual(self.ctl.records["laptop"], before) self.assertEqual(self.command("current"), scene) self.command("browse-end", browse_token="overlay") - self.comp.invalid = False self.tick() self.assertEqual(self.comp.desktop["workspaces"][0]["layout"], "lua:quad") self.assertEqual(self.command("current"), scene) - self.assertEqual(self.ctl.records["laptop"]["assignment"], before["assignment"]) - self.assertEqual(self.ctl.records["laptop"]["window"], before["window"]) - self.assertEqual(self.proc.count["laptop"], 1) self.assertEqual((self.root / "scenes/work.json").read_bytes(), saved) def test_close_before_start_and_late_old_owner_cannot_move_windows(self): @@ -74,7 +67,6 @@ def test_lease_heartbeat_expiry_and_controller_restart_restore(self): self.tick() self.assertFalse(self.ctl.browser.active) self.assertEqual(self.comp.desktop["workspaces"][0]["layout"], "lua:quad") - self.assertEqual(self.proc.count["laptop"], 1) def test_new_scene_restores_base_before_capturing_and_retains_client(self): self.ready() @@ -84,31 +76,19 @@ def test_new_scene_restores_base_before_capturing_and_retains_client(self): self.tick(4) self.assertFalse(self.ctl.browser.active) self.assertEqual(self.command("current")["phase"], "ready") - self.assertEqual(self.ctl.records["laptop"]["assignment"]["zone"], "left") - self.assertEqual(self.proc.count["laptop"], 1) self.command("browse-end", browse_token="overlay") - self.assertEqual(self.ctl.records["laptop"]["assignment"]["zone"], "left") - - def test_disconnect_is_not_blocked_by_preview(self): - self.ready() - self.start() - self.ctl.command({"command": "disconnect", "computer": "laptop"}) - self.tick(5) - self.assertFalse(self.ctl.browser.active) - self.assertFalse(self.ctl.records["laptop"]["desired"]) - self.assertFalse(self.proc.alive) def test_session_capture_keeps_last_checkpoint_during_preview(self): self.ready() self.start() - directory = self.root / "hypertile/streams" - directory.mkdir(parents=True) + directory = self.root / "hypertile/scenes" + directory.mkdir(parents=True, exist_ok=True) (directory / "state.json").write_text(json.dumps(self.ctl.state)) with patch.dict(os.environ, {"XDG_STATE_HOME": str(self.root)}): with self.assertRaisesRegex(ValueError, "layout preview is active"): - fixtures.fixtures.integration.capture(self.comp.snapshot()) + fixtures.scene_recovery.capture(self.comp.snapshot()) - def test_preview_rejects_inflight_stream_and_failed_restore_stays_blocked(self): + def test_preview_rejects_inflight_scene_and_failed_restore_stays_blocked(self): self.save() self.apply() with self.assertRaisesRegex(ValueError, "Wait for the scene"): @@ -119,7 +99,6 @@ def test_preview_rejects_inflight_stream_and_failed_restore_stays_blocked(self): with self.assertRaises(RuntimeError): self.command("browse-end", browse_token="overlay") self.assertIn("1", self.ctl.browser.active) - self.assertEqual(self.ctl.records["laptop"]["observed"], "window-ready") self.comp.fail_layout = False self.tick() self.assertFalse(self.ctl.browser.active) diff --git a/test/content.js b/test/content.js index 13bbbf8..b89e18d 100644 --- a/test/content.js +++ b/test/content.js @@ -1,30 +1,9 @@ const fs = require("fs"), vm = require("vm"), assert = require("assert") const C = {} vm.runInNewContext(fs.readFileSync("plugin/Content.js", "utf8"), C) -const catalog = { streams: [{ computer: "mac", desired: true, observed: "window-ready", assignment: { workspace: "1", zone: "right" } }], - current: { phase: "ready", sources: [{ type: "empty", zone: "left" }] } } -assert.strictEqual(C.source(catalog, "1", "right", true).computer, "mac") -assert.strictEqual(C.source(catalog, "2", "right", true), null) -assert.strictEqual(C.source(catalog, "1", "right", false), null) +const catalog = {current: {phase: "ready", sources: [{type: "empty", zone: "left"}]}} +assert.strictEqual(C.source(catalog, "1", "left", false), null) assert.strictEqual(C.label(C.source(catalog, "1", "left", true)), "Empty") -assert.strictEqual(C.status("window-ready"), "Connected") -assert.strictEqual(C.status("none"), "") -assert.strictEqual(C.status(undefined), "") -assert(C.troubled("needs-attention") && !C.troubled("window-ready")) -assert(C.audio("continuous").includes("continues")) -assert(C.audio("host").includes("muted")) -// Labels, chips and states for every kind of content. -const mac = C.source(catalog, "1", "right", true) -assert.strictEqual(C.label(mac), "mac") -assert.strictEqual(C.chip(mac), "mac") -assert.strictEqual(C.state(mac).text, "Connected") -assert.strictEqual(C.state(mac).urgent, false) -const connecting = { type: "stream", computer: "mac", profile: "desktop", status: "connecting" } -assert.strictEqual(C.label(connecting), "mac · desktop") -assert.strictEqual(C.chip(connecting), "mac · Connecting…") -const broken = { type: "stream", computer: "mac", profile: "desktop", status: "needs-attention", error: "boom" } -assert.strictEqual(C.state(broken).text, "Needs attention") -assert.strictEqual(C.state(broken).urgent, true) assert.strictEqual(C.label(null), "Local windows") assert.strictEqual(C.chip(null), "") assert.strictEqual(C.state(null).text, "") @@ -35,10 +14,6 @@ assert.strictEqual(C.label(app), "org.example.Editor") assert.strictEqual(C.state(app).text, "Pending") assert.strictEqual(C.detail(app), "Open this app on the workspace") assert.strictEqual(C.detail({ type: "local", zone: "left", app_class: "x", status: "ready" }), "One matching window is pinned here") -// Profile traits: only what differs from the defaults. -assert.strictEqual(C.traits({ name: "desktop", audio: "focus", input: "absolute", system_keys: "never", keep_awake: "visible" }), "") -assert.strictEqual(C.traits({ name: "m", audio: "host", input: "relative", system_keys: "always", keep_awake: "always" }), "host audio · captured pointer · system keys") -assert.strictEqual(C.traits({ audio: "continuous" }), "audio continues") // The scene header. assert.strictEqual(C.sceneTitle(null), "No scene") assert.strictEqual(C.sceneTitle({ phase: "none", document: null }), "No scene") @@ -51,14 +26,6 @@ assert.strictEqual(C.sceneModified({ phase: "restored", modified: true, document assert.strictEqual(C.sceneMeta({ phase: "ready" }, "quad", "1"), "quad on workspace 1 · Ready") assert.strictEqual(C.sceneMeta({ phase: "none" }, "quad", "1"), "quad on workspace 1") assert.strictEqual(C.sceneMeta(null, "", ""), "") -const report = {current: {window_ready_ms: 1234, reason: "reconnect"}, last_measurement: {profile: "desktop", - metrics: {decode_ms: 0, network_rtt_ms: 3, rendered_fps: 60, network_drop_pct: 0, jitter_drop_pct: 0}}, - readability: "unverified", advice: []} -assert(C.performance(report).includes("1.23 s")) -assert(C.performance(report).includes("Decode 0.00 ms")) -assert(C.performance(report).includes("Network loss 0.00%")) -assert(!C.performance(report).includes("encode")) -assert(C.performance({}).includes("No completed")) catalog.current.phase = "restored" assert.strictEqual(C.source(catalog, "1", "left", true), null) const E = {} @@ -76,21 +43,6 @@ const fresh = E.identify(original, true) assert.notStrictEqual(fresh.layout_id, original.layout_id) assert.notStrictEqual(E.findLeaf(fresh, "a").node.id, a) console.log("content and scene identities: all checks passed") -// A retained scene binding can outlive the connection and have an empty journal. -let controls = C.streamControls({desired: false, observed: "disconnected", journal: {}}) -assert.strictEqual(controls.reconnect, "connect") -assert.strictEqual(controls.disconnect, false) -assert.strictEqual(controls.restore, false) -controls = C.streamControls({desired: true, observed: "window-ready", window: {pid: 100}}) -assert.strictEqual(controls.reconnect, "reconnect") -assert.strictEqual(controls.disconnect, true) -assert.strictEqual(C.streamControls({desired: false, observed: "restoring", pid: 100}).reconnect, "") -controls = C.streamControls({desired: false, observed: "restore-pending", journal: {output: {}}}) -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/) diff --git a/test/quality.lua b/test/quality.lua deleted file mode 100644 index 9f51598..0000000 --- a/test/quality.lua +++ /dev/null @@ -1,30 +0,0 @@ -package.path = "./?.lua;" .. package.path -local engine = require("hypertile") -engine.provider("test", { columns = { { name = "local" }, { name = "remote" } } }) -local ws = { id = 1, name = "1", tiled_layout = "lua:test" } -local local_win = { address = "local", pid = 1, stable_id = 1, class = "editor", workspace = ws, mapped = true } -local remote = { address = "remote", pid = 2, stable_id = 2, class = "com.moonlight_stream.Moonlight", - title = "Laptop - Moonlight", workspace = ws, mapped = true } -local windows, active, calls = { local_win, remote }, local_win, {} -local function tag(kind) return function(args) args = args or {}; args.kind = kind; return args end end -hl = { get_windows = function() return windows end, get_workspaces = function() return {ws} end, - get_monitors = function() return {} end, get_active_window = function() return active end, - get_active_workspace = function() return ws end, - dsp = { window = { resize = tag("resize"), close = tag("close") }, focus = tag("focus"), release_input_capture = tag("release") }, - dispatch = function(args) calls[#calls + 1] = args end } -local session = require("hypertile-session") -session.stream_assign({ computer = "laptop", profile = "desktop", workspace = "1", layout = "lua:test", zone = "remote", - address = "remote", pid = 2, stable_id = 2, title = remote.title, placed = true }) -session.snapshot() -calls = {} -assert(not session.stream_local({computer = "laptop"}).released and #calls == 0, "never takes focus from another app") -active = remote -assert(session.stream_local({computer = "laptop"}).released) -assert(calls[1].kind == "release" and calls[2].window == "address:local", "explicit return releases capture and focuses the remembered local window") -calls = {} -session.stream_close({computer = "laptop"}) -assert(#calls == 1 and calls[1].kind == "close" and calls[1].window == "address:remote", "reconnect closes only the owned stream without focus") -remote.stable_id = 99 -assert(not session.stream_close({computer = "laptop"}) and #calls == 1, "reused window address is never closed") -assert(not session.stream_local({computer = "laptop"}).released and #calls == 1) -print("quality controls: all checks passed") diff --git a/test/quality.py b/test/quality.py deleted file mode 100644 index 6e9f6e5..0000000 --- a/test/quality.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Real lifecycle transitions with controlled clocks, client exits and typed logs.""" -import copy -import importlib.util -import json -from pathlib import Path -import unittest - -spec = importlib.util.spec_from_file_location("stream_fixtures", Path(__file__).with_name("stream.py")) -fixtures = importlib.util.module_from_spec(spec) -spec.loader.exec_module(fixtures) -from quality import Tracker, VideoStats, settings_key - - -SUMMARY = """00:00:31 - SDL Info (0): Global video stats ----------------------------------------------------------- -Video stream: 2560x1440 60.00 FPS (Codec: HEVC) -Incoming frame rate from network: 60.00 FPS -Decoding frame rate: 59.99 FPS -Rendering frame rate: 59.97 FPS -Host processing latency min/max/average: 1.0/4.0/2.0 ms -Frames dropped by your network connection: 0.01% -Frames dropped due to network jitter: 0.02% -Average network latency: 3 ms (variance: 1 ms) -Average decoding time: 0.10 ms -Average frame queue delay: 0.01 ms -Average rendering time (including monitor V-sync latency): 1.40 ms -""" - - -class ParserTests(unittest.TestCase): - def parse(self, text): - parser = VideoStats() - return [result for line in text.splitlines() if (result := parser.feed(line))] - - def test_versioned_summary_is_numeric_and_does_not_claim_encode_latency(self): - metrics = self.parse(SUMMARY)[0] - self.assertEqual(metrics["rendered_fps"], 59.97) - self.assertEqual(metrics["network_drop_pct"], .01) - self.assertEqual(metrics["host_processing_ms"]["average"], 2) - self.assertNotIn("encode_ms", metrics) - - def test_rejects_text_without_header_nonfinite_out_of_range_and_incomplete(self): - self.assertEqual(self.parse(SUMMARY.split("\n", 1)[1]), []) - for value in ("nan", "-1", "101", "0.01% https://host/?secret=TOKEN"): - self.assertEqual(self.parse(SUMMARY.replace("0.01%", value + "%")), []) - self.assertEqual(self.parse(SUMMARY.replace("0.10 ms", "nan ms")), []) - self.assertEqual(self.parse(SUMMARY.rsplit("Average rendering time", 1)[0]), []) - - def test_multiple_segments_do_not_merge_missing_fields(self): - second = SUMMARY.replace("Average decoding time: 0.10 ms\n", "") - self.assertEqual(len(self.parse(SUMMARY + second)), 1) - without_host = "\n".join(l for l in SUMMARY.splitlines() if not l.startswith("Host processing")) - self.assertNotIn("host_processing_ms", self.parse(without_host)[0]) - - -class QualityTests(unittest.TestCase): - def setUp(self): - fixtures.StreamTests.setUp(self) - self.event_token = None - self.proc.events = lambda r: self.proc.log if r.get("token") and r["token"] == self.event_token else {} - - def controller(self): - ctl = fixtures.s.Controller(self.root, self.config, self.comp, self.proc, lambda *_: self.host, lambda: self.now) - if not hasattr(self, "clock_now"): - self.clock_now = 100 - ctl.quality = Tracker(ctl, clock=lambda: self.clock_now, boot="test") - return ctl - - connect = fixtures.StreamTests.connect - - def tick(self, count=1): - for _ in range(count): - self.ctl.tick() - self.now += 1 - self.clock_now += 1 - - def ready(self): - self.connect() - self.tick(3) - self.window() - self.tick() - - def window(self): - self.comp.desktop["windows"] = [{"address": "a", "pid": 123, "stable_id": 1, "class": fixtures.s.CLASS, - "title": "Laptop - Moonlight", "workspace": "1", "size": {"x": 1000, "y": 600}}] - - def close(self): - self.event_token = self.ctl.records["laptop"]["token"] - self.proc.alive = None - self.proc.log = {"quit": True, "closed": True} - self.comp.desktop["windows"] = [] - - def test_window_timing_uses_monotonic_clock_and_is_not_reset_by_polling(self): - self.now = 10000 - self.connect() - self.now -= 3600 - self.tick(3) - self.window() - self.tick() - first = self.ctl.quality.report(self.ctl.records["laptop"])["current"]["window_ready_ms"] - self.assertEqual(first, 3000) - self.tick(4) - self.assertEqual(self.ctl.quality.report(self.ctl.records["laptop"])["current"]["window_ready_ms"], first) - - def test_reconnect_is_idempotent_keeps_assignment_and_journal_and_does_not_disconnect(self): - self.ready() - original = copy.deepcopy(self.ctl.records["laptop"]) - first = self.ctl.command({"command": "reconnect", "computer": "laptop"}) - self.assertEqual(self.ctl.command({"command": "reconnect", "computer": "laptop"})["operation"], first["operation"]) - self.tick() - self.assertIn(("stream_close", {"computer": "laptop"}), self.comp.calls) - self.close() - self.tick(3) - self.proc.log = {} - self.tick() - self.window() - self.tick() - r = self.ctl.records["laptop"] - self.assertTrue(r["desired"]) - self.assertEqual(r["phase"], "watching") - self.assertEqual(r["assignment"], original["assignment"]) - self.assertEqual(r.get("journal"), original.get("journal")) - self.assertEqual(self.host.calls, []) - self.assertEqual(self.proc.count, 2) - self.assertEqual(self.ctl.quality.report(r)["current"]["reason"], "reconnect") - - def test_disconnect_cancels_reconnect_before_late_launch(self): - self.ready() - self.ctl.command({"command": "reconnect", "computer": "laptop"}) - self.tick() - self.ctl.command({"command": "disconnect", "computer": "laptop"}) - self.close() - self.tick(12) - self.assertFalse(self.ctl.records["laptop"]["desired"]) - self.assertEqual(self.proc.count, 1) - - def test_restart_mid_reconnect_retains_single_operation(self): - self.ready() - operation = self.ctl.command({"command": "reconnect", "computer": "laptop"})["operation"] - self.tick() - self.ctl = self.controller() - self.assertEqual(self.ctl.records["laptop"]["operation"], operation) - self.close() - self.tick(4) - self.assertEqual(self.proc.count, 2) - - def test_measurement_is_cancelled_when_disconnected_or_rebooted(self): - for cancel in ("disconnect", "boot"): - with self.subTest(cancel=cancel): - self.ready() - r = self.ctl.records["laptop"] - run = self.ctl.quality.current(r) - run["quality_parser"] = True - self.ctl.command({"command": "measure", "computer": "laptop", "seconds": 10}) - if cancel == "disconnect": - self.ctl.command({"command": "disconnect", "computer": "laptop"}) - self.close() - self.tick(2) - else: - self.ctl.quality.boot = "another-boot" - self.clock_now += 20 - self.ctl.quality.due() - self.assertEqual(run["measurement"]["status"], "cancelled") - - def test_measurement_due_once_and_late_logger_result_is_attached_to_old_run(self): - self.ready() - r = self.ctl.records["laptop"] - old = self.ctl.quality.current(r) - old["quality_parser"] = True - self.ctl.quality.measure(r, 10) - self.clock_now += 11 - self.ctl.quality.due() - operation = r["operation"] - self.ctl.quality.due() - self.assertEqual(r["operation"], operation) - self.assertEqual(old["measurement"]["status"], "collecting") - self.assertNotEqual(self.ctl.quality.current(r)["id"], old["id"]) - event = {"closed": True, "quality_parser": 1, "performance_at": 123, "performance": {"decode_ms": .3}} - (self.root / (old["token"] + ".events")).write_text(json.dumps(event)) - self.ctl.quality.harvest() - report = self.ctl.quality.report(r) - self.assertEqual(report["last_measurement"]["id"], old["id"]) - self.assertEqual(old["measurement"]["status"], "complete") - self.assertNotIn("token", json.dumps(report)) - - def test_readability_and_metrics_are_not_reused_for_changed_settings_or_size(self): - self.ready() - r = self.ctl.records["laptop"] - self.ctl.quality.assess(r, "readable") - self.assertEqual(self.ctl.quality.report(r)["readability"], "readable") - self.comp.desktop["windows"][0]["size"]["x"] = 500 - self.tick() - self.assertEqual(self.ctl.quality.report(r)["readability"], "unverified") - run = self.ctl.quality.current(r) - self.ctl.quality.assess(r, "readable") - run["metrics"] = {"decode_ms": .5} - r["settings"]["bitrate"] = 10000 - self.assertIsNone(self.ctl.quality.report(r)["last_measurement"]) - self.assertEqual(self.ctl.quality.report(r)["readability"], "unverified") - - def test_measurement_requires_new_logger_and_history_is_bounded(self): - self.ready() - r = self.ctl.records["laptop"] - with self.assertRaisesRegex(ValueError, "Reconnect once"): - self.ctl.quality.measure(r, 10) - for _ in range(30): - self.ctl.quality.begin(r, "retry") - self.assertEqual(len(self.ctl.quality.runs("laptop")), 20) - self.assertEqual(len(self.ctl.quality.report(r)["history"]), 5) - self.assertNotEqual(settings_key({"bitrate": 1}), settings_key({"bitrate": 2})) - - def test_fast_polling_is_limited_to_transitions_and_preserves_backoff(self): - self.assertEqual(self.ctl.tick_interval(), 1) - self.connect() - self.assertEqual(self.ctl.tick_interval(), .2) - r = self.ctl.records["laptop"] - r["next_at"] = self.now + 10 - self.assertEqual(self.ctl.tick_interval(), 1) - r.update(phase="watching", next_at=0) - self.assertEqual(self.ctl.tick_interval(), 1) - r["phase"] = "reconnect-stop" - self.assertEqual(self.ctl.tick_interval(), .2) - - def test_new_compositor_run_cannot_relabel_old_client_statistics(self): - self.ready() - r = self.ctl.records["laptop"] - old = self.ctl.quality.current(r) - new = self.ctl.quality.begin(r, "compositor-recovery") - r["phase"] = "restart-stop" - self.ctl.quality.observe(r, "restart-stop", self.clock_now, self.comp.snapshot()) - self.assertNotIn("token", new) - self.assertIn("token", old) - r.update(phase="connecting", token="f" * 32) - self.ctl.quality.observe(r, "launch", self.clock_now, self.comp.snapshot()) - self.assertEqual(new["token"], "f" * 32) - self.assertNotEqual(old["token"], new["token"]) - - def test_unsupported_client_reports_limit_without_requesting_repeated_reconnects(self): - self.ready() - r = self.ctl.records["laptop"] - r["resolved"]["client_version"] = "6.2.0" - with self.assertRaisesRegex(ValueError, "supports Moonlight Qt 6.1"): - self.ctl.quality.measure(r, 10) - self.assertIn("Statistics overlay", self.ctl.quality.report(r)["collection_reason"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/scenes.lua b/test/scenes.lua index 1208a89..813aeb0 100644 --- a/test/scenes.lua +++ b/test/scenes.lua @@ -33,10 +33,6 @@ for _, w in ipairs(windows) do end engine.recalculate(engine.live.test.compiled, ctx, engine.state.test) assert(ctx.targets[1].box.x == 0 and ctx.targets[2].box.x == 300, "local app pin and empty reservation preserve fill") -local ok, error = pcall(session.stream_check, { computer = "laptop", workspace = "1", layout = "lua:test", zone = "right" }) -assert(not ok and tostring(error):find("intentionally empty"), "stream cannot claim Empty") -local checked = session.stream_check({ computer = "laptop", workspace = "1", layout = "lua:test", zone = "old-name", zone_id = "b" }) -assert(checked.zone == "middle" and checked.zone_id == "b", "stable identity resolves a renamed zone") engine.state.test.pins.a = "middle" engine.provider("other", spec) engine.state.other.pins.a = "left" @@ -48,20 +44,7 @@ windows[3] = { address = "c", stable_id = 3, pid = 33, class = "editor", workspa result = session.scene_content_apply(request) assert(result.results[1].status == "needs-attention" and #result.pins == 0, "ambiguous app never picks an arbitrary window") windows[3] = nil -windows[2].class, windows[2].title = "com.moonlight_stream.Moonlight", "Laptop - Moonlight" -session.stream_assign({ computer = "laptop", profile = "desktop", workspace = "1", layout = "lua:test", zone = "middle", - address = "b", stable_id = 2, pid = 22, title = "Laptop - Moonlight", placed = true }) -calls = {} -session.stream_shortcut({ computer = "laptop", action = "clipboard" }) -assert(#calls == 1 and calls[1].kind == "focus", "explicit clipboard action focuses the client for its Wayland offer") -timers[1]() -assert(calls[2].key == "V" and calls[2].state == "down" and calls[2].window == "address:b") -timers[2]() -assert(calls[3].state == "up" and calls[3].window == "address:b", "shortcut releases its synthetic key on the same owned window") -windows[2].stable_id = 999 -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" } } }) +local 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. diff --git a/test/scenes.py b/test/scenes.py index 52d1a88..793d401 100644 --- a/test/scenes.py +++ b/test/scenes.py @@ -1,20 +1,16 @@ -"""Scenes exercised with real stream state transitions and fake host/compositor IO.""" +"""Generic scene fixtures and definition checks, with no host or client process.""" import copy -import importlib.util import json +import os from pathlib import Path +import sys import tempfile import unittest from unittest.mock import patch -from types import SimpleNamespace - -spec = importlib.util.spec_from_file_location("stream_fixtures", Path(__file__).with_name("stream.py")) -fixtures = importlib.util.module_from_spec(spec) -spec.loader.exec_module(fixtures) -s = fixtures.s -from scenes import Manager, identify -from audio import host_headset - +sys.path[:0] = [str(Path(__file__).resolve().parents[1] / p) for p in ("scenes", "session")] +from scene_service import SceneController +from scenes import identify +import scene_recovery class Layouts: def __init__(self): @@ -35,392 +31,101 @@ def persist(self, workspace, rule): pass -class Compositor(fixtures.Compositor): - def __init__(self, layouts): - super().__init__() - self.layouts = layouts - self.desktop["scene_content"] = {} - self.desktop["layouts"] = {"quad": {"spec": layouts.entries[0]["spec"]}} - self.fail_layout = False +class Compositor: + instance = "one" + def __init__(self, layouts): + self.desktop = {"windows": [], "workspace": "1", "monitors": [], "scene_content": {}, + "layouts": {"quad": {"spec": layouts.entries[0]["spec"]}}, + "workspaces": [{"selector": "1", "layout": "lua:quad", "visible": False}]} + self.calls, self.fail_layout = [], False + def snapshot(self): + return copy.deepcopy(self.desktop) def call(self, method, args): + self.calls.append((method, copy.deepcopy(args))) if method == "scene_layout": if self.fail_layout: raise RuntimeError("injected scene layout failure") self.desktop["workspaces"][0]["layout"] = args["layout"] self.desktop["layouts"][args["layout"][4:]] = {"spec": args.get("spec", {})} + return "scene rule" if method == "scene_content_apply": - self.calls.append((method, copy.deepcopy(args))) self.desktop["scene_content"][args["workspace"]] = copy.deepcopy(args) return {"results": [], "pins": []} if method == "scene_clear": self.desktop["scene_content"].pop(args["workspace"], None) - result = super().call(method, args) - if method == "stream_check": - entry = self.layouts.get(args["layout"][4:]) - leaf = next(n for n in entry["spec"]["columns"] if (n["id"] == args["zone_id"] if args.get("zone_id") else n["name"] == args["zone"])) - return {"zone": leaf["name"], "zone_id": leaf["id"]} - return result - - -class Processes: - def __init__(self, comp): - self.comp, self.alive, self.count = comp, {}, {} - - def pid(self, record): - return self.alive.get(record["computer"]) - - def events(self, record): - return {} - - def launch(self, record): - computer = record["computer"] - self.count[computer] = self.count.get(computer, 0) + 1 - pid = 1000 + sum(self.count.values()) - self.alive[computer] = pid - self.comp.desktop["windows"].append({"address": str(pid), "stable_id": pid, "pid": pid, "class": s.CLASS, - "title": record["config"]["title"], "workspace": record["assignment"]["workspace"]}) - - def stop(self, record, force=False): - pid = self.alive.pop(record["computer"], None) - self.comp.desktop["windows"] = [w for w in self.comp.desktop["windows"] if w["pid"] != pid] - + return True class SceneTests(unittest.TestCase): def setUp(self): - self.directory = tempfile.TemporaryDirectory() - self.addCleanup(self.directory.cleanup) - self.root = Path(self.directory.name) - self.config = self.root / "computers.json" - computers = {"laptop": fixtures.computer(), "second": fixtures.computer()} - computers["laptop"]["profiles"]["meeting"] = {**computers["laptop"]["profiles"]["desktop"], "audio": "continuous", "keep_awake": "always"} - computers["second"].update(pairing_uuid="22345678-1234-1234-1234-123456789ABC", title="Second - Moonlight") - self.config.write_text(json.dumps({"version": 1, "computers": computers})) + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + env = patch.dict(os.environ, XDG_STATE_HOME=str(self.root)) + env.start() + self.addCleanup(env.stop) self.layouts = Layouts() self.comp = Compositor(self.layouts) - self.proc = Processes(self.comp) - self.hosts = {"laptop.example": fixtures.Host("external")} self.now = 100 self.ctl = self.controller() - def controller(self): - ctl = s.Controller(self.root, self.config, self.comp, self.proc, lambda c, _: self.hosts[c["host"]], lambda: self.now) - ctl.scenes = Manager(ctl, lambda: s.configuration(self.config), self.layouts, self.root / "scenes") + ctl = SceneController(self.root / "hypertile/scenes", self.root / "scenes.json", self.comp, now=lambda: self.now) + ctl.scenes.layouts = self.layouts return ctl - - def tick(self, n=1): - for _ in range(n): - self.ctl.tick() - self.now += 1 - - def command(self, action, **kwargs): - return self.ctl.command({"command": "scene", "action": action, "workspace": "1", **kwargs}) - - def save(self, name="work", computer="laptop", zone="right", **extra): - doc = {"version": 1, "layout": "quad", "sources": {zone: {"type": "stream", "computer": computer, "profile": "desktop"}, **extra}} - return self.command("save", name=name, document=doc) - + def tick(self, count=1): + for _ in range(count): self.ctl.tick() + def command(self, action, **kw): + return self.ctl.command({"command": "scene", "action": action, "workspace": "1", **kw}) + def save(self, name="work", zone="right"): + return self.command("save", name=name, document={"version": 1, "layout": "quad", "sources": {zone: {"type": "empty"}}}) def apply(self, name="work"): return self.command("apply", name=name) - def ready(self): self.save() self.apply() - self.tick(6) + self.tick(3) self.assertEqual(self.command("current")["phase"], "ready") - - def test_save_versioned_ids_is_private_and_does_not_connect(self): - doc = self.save()["document"] - self.assertEqual(doc["layout_id"], "layout-one") - self.assertEqual(doc["sources"]["z-right"]["computer"], "laptop") - self.assertEqual((self.root / "scenes/work.json").stat().st_mode & 0o777, 0o600) - self.assertFalse(self.proc.alive) - - def test_apply_is_idempotent_and_move_reuses_connection(self): + def test_private_save_and_idempotent_apply(self): self.ready() - original = self.apply() - pid = self.proc.alive["laptop"] - self.assertEqual(self.apply()["operation"], original["operation"]) - self.save("move", zone="left", extra={"type": "empty"}) - self.apply("move") - self.tick(4) - self.assertEqual(self.proc.alive["laptop"], pid) - self.assertEqual(self.ctl.records["laptop"]["assignment"]["zone"], "left") - self.assertEqual(self.command("current")["phase"], "ready") - self.assertFalse(any(m == "stream_focus" for m, _ in self.comp.calls)) - - def test_latest_scene_cancels_queued_source(self): - self.save() - self.save("other", computer="second") + self.assertEqual((self.root / "scenes/work.json").stat().st_mode & 0o777, 0o600) + self.assertEqual(self.command("current")["document"]["layout_id"], "layout-one") + count = len(self.comp.calls) self.apply() - self.apply("other") - self.tick(9) - self.assertEqual(self.proc.count.get("laptop", 0), 0) - self.assertEqual(self.proc.count["second"], 1) - - def test_content_picker_moves_existing_computer_instead_of_duplicating(self): - self.ready() - pid = self.proc.alive["laptop"] - self.command("content", type="stream", computer="laptop", profile="desktop", zone="left") - self.tick(4) - current = self.command("current") - self.assertEqual(len(current["document"]["sources"]), 1) - self.assertEqual(self.ctl.records["laptop"]["assignment"]["zone"], "left") - self.assertEqual(self.proc.alive["laptop"], pid) - - def test_content_change_keeps_scene_name_until_saved_back(self): - self.ready() - self.command("content", type="empty", zone="left") - self.tick(6) - scene = self.command("current") - self.assertEqual(scene["document"]["name"], "work") - self.assertTrue(scene["modified"]) - self.assertEqual(scene["document"]["sources"]["z-left"]["type"], "empty") - saved = json.loads((self.root / "scenes/work.json").read_text()) - self.assertNotIn("z-left", saved["sources"]) - self.assertFalse(self.command("save", name="work")["document"]["sources"]["z-left"] is None) - scene = self.command("current") - self.assertFalse(scene["modified"]) - self.assertEqual(json.loads((self.root / "scenes/work.json").read_text())["sources"]["z-left"]["type"], "empty") - self.assertFalse(self.command("apply", name="work")["modified"]) - - def test_saving_a_restored_arrangement_makes_it_the_applied_scene(self): - self.ready() - self.command("restore") - self.tick(8) - self.assertEqual(self.command("current")["phase"], "restored") - self.assertEqual(self.command("save", name="again")["document"]["name"], "again") - scene = self.command("current") - self.assertEqual((scene["phase"], scene["document"]["name"], scene["modified"]), ("ready", "again", False)) - self.tick(4) - self.assertEqual(self.command("current")["phase"], "ready") - self.assertEqual(self.proc.count["laptop"], 1) - self.command("content", type="empty", zone="left") - self.tick(6) - scene = self.command("current") - self.assertEqual((scene["document"]["name"], scene["modified"]), ("again", True)) - - def test_profile_switch_and_disconnect_cancel_replacement(self): - self.ready() - self.ctl.command({"command": "profile", "computer": "laptop", "profile": "meeting"}) - self.ctl.command({"command": "disconnect", "computer": "laptop"}) - self.tick(12) - self.assertEqual(self.proc.count["laptop"], 1) - self.assertFalse(self.ctl.records["laptop"]["desired"]) - - def test_profile_switch_waits_for_old_exit_then_uses_audio_policy(self): - self.ready() - self.ctl.command({"command": "profile", "computer": "laptop", "profile": "meeting"}) - self.tick(12) - self.assertEqual(self.proc.count["laptop"], 2) - r = self.ctl.records["laptop"] - self.assertEqual(r["profile"], "meeting") - args = s.stream_argv(r["config"], r["settings"]) - self.assertIn("--no-mute-on-focus-loss", args) - self.assertIn("--keep-awake", args) - - def test_restart_retains_scene_and_process(self): - self.ready() - pid = self.proc.alive["laptop"] - self.ctl = self.controller() - self.comp.desktop["scene_content"] = {} - self.tick(5) - self.assertEqual(self.proc.alive["laptop"], pid) - self.assertEqual(self.proc.count["laptop"], 1) - self.assertIn("1", self.comp.desktop["scene_content"]) - - def test_invalid_reference_preflight_does_not_touch_active_desktop(self): - self.ready() - path = self.root / "scenes/work.json" - doc = json.loads(path.read_text()) - doc["sources"]["deleted-id"] = doc["sources"].pop("z-right") - path.write_text(json.dumps(doc)) - self.comp.calls.clear() - with self.assertRaisesRegex(ValueError, "zone is missing"): - self.apply() - self.assertFalse(self.comp.calls) - self.assertTrue(self.proc.alive) - - def test_layout_and_zone_rename_follow_ids_without_relaunch(self): + self.tick() + self.assertEqual(len(self.comp.calls), count) + def test_named_edit_and_restore(self): self.ready() - e = self.layouts.entries[0] - e["name"] = "renamed" - e["spec"]["columns"][1]["name"] = "renamed-zone" - e["spec"]["fill"][1] = "renamed-zone" - self.comp.desktop["workspaces"][0]["layout"] = "lua:renamed" - self.comp.desktop["layouts"]["renamed"] = {"spec": copy.deepcopy(e["spec"])} - self.tick(3) - self.assertEqual(self.command("current")["document"]["layout"], "renamed") - self.assertEqual(self.ctl.records["laptop"]["assignment"]["zone"], "renamed-zone") - self.assertEqual(self.proc.count["laptop"], 1) - - def test_empty_scene_restores_initial_layout_and_sources(self): - self.ctl.command({"command": "connect", "computer": "laptop", "zone": "left", "workspace": "1"}) - self.tick(5) - self.command("save", name="local", document={"version": 1, "layout": "quad", "sources": {"right": {"type": "empty"}}}) - self.apply("local") - self.tick(6) - self.assertFalse(self.proc.alive) + self.command("content", zone="left", type="local") + self.tick() + self.assertEqual(self.command("current")["document"]["name"], "work") + self.assertTrue(self.command("current")["modified"]) self.command("restore") - self.tick(10) - self.assertTrue(self.proc.alive) - self.assertEqual(self.ctl.records["laptop"]["assignment"]["zone"], "left") + self.tick() self.assertEqual(self.command("current")["phase"], "restored") - - def test_all_fill_zones_and_duplicate_computer_are_rejected(self): - with self.assertRaisesRegex(ValueError, "one fill zone"): - self.command("save", name="bad", document={"version": 1, "layout": "quad", "sources": {n: {"type": "empty"} for n in ("left", "right", "extra")}}) - with self.assertRaisesRegex(ValueError, "only one"): - self.save(extra={"type": "stream", "computer": "laptop", "profile": "desktop"}) - with self.assertRaisesRegex(ValueError, "app class can occupy only one"): - self.command("save", name="bad", document={"version": 1, "layout": "quad", "sources": { - n: {"type": "local", "app_class": "editor"} for n in ("left", "right")}}) - - def test_offline_source_reports_partial_after_local_content_applies(self): - self.hosts["laptop.example"].error = "pairing-required" - self.save(extra={"type": "empty"}) + def test_invalid_empty_and_legacy_sources_do_not_write(self): + for sources, message in [({n: {"type": "empty"} for n in ("left", "right", "extra")}, "Leave one"), + ({"right": {"type": "stream", "computer": "laptop"}}, "Legacy stream")]: + with self.assertRaisesRegex(ValueError, message): + self.command("save", name="bad", document={"version": 1, "layout": "quad", "sources": sources}) + self.assertFalse((self.root / "scenes/bad.json").exists()) + def test_failure_retry_and_stable_zone_rename(self): + self.save() self.apply() - self.tick(8) - self.assertEqual(self.command("current")["phase"], "partial") - self.assertIn("1", self.comp.desktop["scene_content"]) - self.assertEqual(next(x for x in self.command("current")["sources"] if x["type"] == "stream")["status"], "needs-attention") - - def test_repeated_stream_connect_accepts_identified_assignment(self): - self.ready() - pid = self.proc.alive["laptop"] - self.ctl.command({"command": "connect", "computer": "laptop", "zone": "right"}) - self.assertEqual(self.proc.alive["laptop"], pid) - - def test_scene_swap_retains_identity_connection_and_saved_definition(self): - self.ready() - original = copy.deepcopy(self.ctl.records["laptop"]) - saved = (self.root / "scenes/work.json").read_bytes() - source = {**original["window"], "computer": "laptop", "before": "right", "before_id": "z-right", - "zone": "left", "zone_id": "z-left", "pin": "right"} - local = {"address": "editor", "stable_id": 2, "pid": 2, "before": "left", "before_id": "z-left", - "zone": "right", "zone_id": "z-right"} - self.comp.swap_plan = {"workspace": "1", "layout": "lua:quad", "windows": [source, local]} - request = {"command": "swap", "windows": [{k: w[k] for k in ("address", "stable_id")} for w in (source, local)]} - self.assertTrue(self.ctl.command(request)["swapped"]) - self.tick(4) - current = self.ctl.records["laptop"] - self.assertEqual(current["assignment"]["zone"], "left") - self.assertEqual(current["assignment"]["zone_id"], "z-left") - for key in ("window", "generation", "operation", "profile", "token", "journal"): - self.assertEqual(current.get(key), original.get(key)) - self.assertEqual(self.proc.count["laptop"], 1) - scene = self.command("current") - self.assertTrue(scene["modified"]) - self.assertEqual(scene["document"]["sources"]["z-left"]["computer"], "laptop") - self.assertEqual((self.root / "scenes/work.json").read_bytes(), saved) - # A controller restart must retain the moved assignment and scene. - self.ctl = self.controller() - self.tick(3) - self.assertEqual(self.ctl.records["laptop"]["assignment"]["zone_id"], "z-left") - self.assertEqual(self.proc.count["laptop"], 1) - # A deleted/recreated origin with the same name must not pass validation. - source.update(before="left", before_id="replacement-id", zone="right", zone_id="z-right") - with self.assertRaisesRegex(ValueError, "assignment changed"): - self.ctl.command(request) - self.assertNotIn("swap", self.ctl.state) - - def test_clipboard_is_an_explicit_shortcut_with_no_content_in_journal(self): - self.ready() - self.assertFalse(any(m == "stream_shortcut" for m, _ in self.comp.calls)) - self.ctl.command({"command": "clipboard", "computer": "laptop"}) - self.assertIn(("stream_shortcut", {"computer": "laptop", "action": "clipboard"}), self.comp.calls) - self.assertNotIn("clipboard", (self.root / "state.json").read_text()) - - def test_mac_clipboard_limit_is_explicit_and_never_injects_keys(self): - self.ready() - record = self.ctl.records["laptop"] - record["config"]["platform"] = "macos" - self.assertEqual(self.ctl.public(record)["clipboard"]["state"], "unsupported") - with self.assertRaisesRegex(ValueError, "does not implement"): - self.ctl.command({"command": "clipboard", "computer": "laptop"}) - self.assertFalse(any(m == "stream_shortcut" for m, _ in self.comp.calls)) - - def test_system_key_capture_requires_a_profile_choice(self): - computer = fixtures.computer() - profile = computer["profiles"]["desktop"] - argv = s.stream_argv(computer, profile) - self.assertEqual(argv[argv.index("--capture-system-keys") + 1], "never") - profile["system_keys"] = "always" - argv = s.stream_argv(computer, profile) - self.assertEqual(argv[argv.index("--capture-system-keys") + 1], "always") - profile["system_keys"] = "invalid" - self.config.write_text(json.dumps({"version": 1, "computers": {"laptop": computer}})) - with self.assertRaisesRegex(ValueError, "system key capture"): - s.configuration(self.config) - - def test_rename_while_queued_uses_resolved_zone(self): - self.ready() - self.save("move", zone="left") - self.apply("move") - self.layouts.entries[0]["spec"]["columns"][0]["name"] = "renamed" - self.layouts.entries[0]["spec"]["fill"][0] = "renamed" - self.tick(5) - self.assertEqual(self.ctl.records["laptop"]["assignment"]["zone"], "renamed") - self.assertEqual(self.command("current")["phase"], "ready") - - def test_identity_generation_does_not_mutate_input(self): - original = {"columns": [{"name": "a"}, {"name": "b"}]} - result = identify(original) - self.assertNotIn("layout_id", original) - self.assertNotEqual(result["columns"][0]["id"], result["columns"][1]["id"]) - self.assertEqual(identify(result), result) - - def test_snapshot_scene_does_not_undo_a_disconnect(self): - self.ready() - doc = copy.deepcopy(self.command("current")["document"]) - self.ctl.command({"command": "disconnect", "computer": "laptop"}) - self.tick(4) - self.ctl.scenes.records.clear() - self.ctl.command({"command": "session-restore", "sources": [], "scenes": [{"workspace": "1", "document": doc}]}) - self.tick(10) - self.assertFalse(self.ctl.records["laptop"]["desired"]) - self.assertEqual(self.proc.count["laptop"], 1) - - def test_headset_mutes_only_the_owned_client(self): - calls = [] - def run(argv, **_): - calls.append(argv) - return SimpleNamespace(stdout=json.dumps([ - {"index": 1, "mute": False, "properties": {"application.process.id": "100"}}, - {"index": 2, "mute": False, "properties": {"application.process.id": "200"}}])) - with patch("audio.shutil.which", return_value="/usr/bin/pactl"): - self.assertEqual(host_headset(100, run)["state"], "local-muted") - self.assertEqual(calls[1:], [["pactl", "set-sink-input-mute", "1", "1"]]) - - def test_host_audio_resolves_native_pipewire_client_without_muting_others(self): - calls = [] - inputs = [ - {"index": 10, "client": "6131", "mute": False, "properties": {"client.id": "82"}}, - {"index": 11, "client": "6132", "mute": False, "properties": {}}, - {"index": 12, "client": "missing", "mute": False, "properties": {"application.name": "Moonlight"}}, - {"index": 13, "client": "6131", "mute": False, "properties": {"application.process.id": "200"}}] - clients = [{"index": 6131, "properties": {"application.process.id": "100"}}, - {"index": 6132, "properties": {"application.process.id": "200"}}] - def run(argv, **_): - calls.append(argv) - return SimpleNamespace(stdout=json.dumps(clients if argv[-1] == "clients" else inputs)) - with patch("audio.shutil.which", return_value="/usr/bin/pactl"): - self.assertEqual(host_headset(100, run)["state"], "local-muted") - self.assertEqual([v for v in calls if v[1] == "set-sink-input-mute"], - [["pactl", "set-sink-input-mute", "10", "1"]]) - - def test_layout_failure_remains_recoverable(self): - self.ready() - self.save("move", zone="left") self.comp.fail_layout = True - self.apply("move") self.tick() self.assertEqual(self.command("current")["phase"], "needs-attention") self.comp.fail_layout = False - self.command("restore") - self.tick(8) - self.assertEqual(self.command("current")["phase"], "restored") - + self.command("retry") + self.tick() + self.assertEqual(self.command("current")["phase"], "ready") + self.layouts.entries[0]["spec"]["columns"][1]["name"] = "renamed" + self.layouts.entries[0]["spec"]["fill"][1] = "renamed" + self.tick(3) + self.assertEqual(self.command("current")["document"]["sources"]["z-right"]["zone"], "renamed") + def test_identity_generation_is_pure(self): + source = {"columns": [{"name": "left"}, {"name": "right"}]} + result = identify(source) + self.assertNotIn("layout_id", source) + self.assertNotEqual(result["columns"][0]["id"], result["columns"][1]["id"]) -if __name__ == "__main__": - unittest.main() +if __name__ == "__main__": unittest.main() diff --git a/test/stream.lua b/test/stream.lua deleted file mode 100644 index f867012..0000000 --- a/test/stream.lua +++ /dev/null @@ -1,164 +0,0 @@ -package.path = "./?.lua;" .. package.path -local engine = require("hypertile") -local spec = { columns = { { name = "local" }, { name = "remote" } }, - fill = { "remote", "local" }, empty = "collapse", single = "collapse" } -local compiled = engine.compile(spec) -local function context(workspace, addresses) - local ctx = { targets = {}, area = { x = 0, y = 0, w = 1000, h = 600 } } - for _, address in ipairs(addresses) do - local t = { window = { address = address, workspace = { id = workspace } } } - function t:place(box) self.box = box end - ctx.targets[#ctx.targets + 1] = t - end - return ctx -end -local state = { pins = { local1 = "remote" }, sizes = {}, reservations = { ["1"] = { remote = true } } } -local ctx = context(1, { "local1" }) -engine.recalculate(compiled, ctx, state) -assert(ctx.targets[1].box.w == 500 and ctx.targets[1].box.x == 0, "offline reservation defeats local pins and single collapse") -ctx = context(2, { "local1" }) -engine.recalculate(compiled, ctx, state) -assert(ctx.targets[1].box.w == 1000, "reservation does not leak to another workspace") -state.reservations["1"].remote = "stream" -ctx = context(1, { "local1", "stream", "local2", "local3" }) -engine.recalculate(compiled, ctx, state) -assert(ctx.targets[2].box.x == 500 and ctx.targets[2].box.w == 500, "owned window takes its zone") -for _, i in ipairs({ 1, 3, 4 }) do assert(ctx.targets[i].box.x == 0, "overflow cannot enter a reserved zone") end -state.reservations = {} -ctx = context(1, { "local1" }) -engine.recalculate(compiled, ctx, state) -assert(ctx.targets[1].box.w == 1000, "release restores ordinary fill") - -local ws = { id = 1, name = "1", tiled_layout = "lua:test" } -local windows = { { address = "a", pid = 123, stable_id = 9, class = "com.moonlight_stream.Moonlight", - title = "Laptop - Moonlight", mapped = true, workspace = ws } } -local calls = {} -local rules = {} -local function tagged(name) - return function(args) args.kind = name; return args end -end -hl = { - window_rule = function(rule) rules[rule.name] = rule end, - get_workspaces = function() return { ws } end, - get_windows = function() return windows end, - dsp = { window = { resize = tagged("resize"), move = tagged("move"), fullscreen_state = tagged("fullscreen"), - float = tagged("float") }, focus = tagged("focus") }, - dispatch = function(args) calls[#calls + 1] = args end, -} -engine.provider("test", spec) -local session = require("hypertile-session") -local source = { computer = "laptop", profile = "desktop", workspace = "1", layout = "lua:test", zone = "remote", title = "Laptop - Moonlight" } -session.stream_assign(source) -assert(engine.state.test.reservations["1"].remote == true) -assert(rules["hypertile-stream-laptop"].enabled and rules["hypertile-stream-laptop"].no_initial_focus, - "final-window launch rule exists before the final window") -assert(rules["hypertile-stream-laptop"].workspace == "1 silent") -source.address, source.pid, source.stable_id, source.title = "a", 123, 9, "Laptop - Moonlight" -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.follow == false and call.window == "address:a") end -end -calls = {} -source.placed = true -windows[1].fullscreen = 2 -session.stream_assign(source) -for _, call in ipairs(calls) do assert(call.kind ~= "fullscreen", "reconciliation preserves later user fullscreen") end -local ok = pcall(session.stream_assign, { computer = "other", workspace = "1", layout = "lua:test", zone = "remote" }) -assert(not ok, "second owner rejected") -ok = pcall(session.stream_assign, { computer = "other", workspace = "1", layout = "lua:test", zone = "local" }) -assert(not ok, "one overflow zone remains available") -source.stable_id = 10 -ok = pcall(session.stream_assign, source) -assert(not ok, "reused address/pid is not enough to identify a window") -session.stream_release({ computer = "laptop" }) -assert(not engine.state.test.reservations["1"].remote, "release clears the reservation") -assert(rules["hypertile-stream-laptop"].enabled == false, "release disables the temporary host rule") - --- Real engine assignment with an uncapped fill sequence: swapping must not --- stack an unrelated local window into the newly freed source zone. -spec = { layout_id = "layout", columns = { { name = "one", id = "id-one" }, { name = "two", id = "id-two" }, - { name = "three", id = "id-three" }, { name = "four", id = "id-four" } }, - fill = { "one", "two", "three", "four" }, empty = "keep", single = "slot" } -engine.provider("test", spec) -windows = {} -for i, address in ipairs({ "a", "b", "c", "d" }) do - windows[i] = { address = address, pid = i, stable_id = i, mapped = true, workspace = ws, - class = (i == 1 or i == 4) and "com.moonlight_stream.Moonlight" or "terminal", - title = address .. " - Moonlight", fullscreen = 0 } -end -local function assign_source(id, i, zone) - local w = windows[i] - local request = { computer = id, profile = "desktop", workspace = "1", layout = "lua:test", zone = zone, - zone_id = engine.live.test.compiled.leaf_opts[zone].id, - address = w.address, pid = w.pid, stable_id = w.stable_id, title = w.title, placed = true } - session.stream_assign(request) - return request -end -local laptop = assign_source("laptop", 1, "two") -assign_source("second", 4, "four") -engine.live.test.orders["1"] = { "a", "b", "c", "d" } -local function positions() - local targets = {} - for _, w in ipairs(windows) do targets[#targets + 1] = { window = w } end - local s = engine.state.test - local buckets = engine.assign(engine.live.test.compiled, targets, - { pins = s.pins, exclusive_pins = s.exclusive_pins, reserved = s.reservations["1"] }) - local result = {} - for zone, bucket in pairs(buckets) do - for _, t in ipairs(bucket) do result[t.window.address] = zone end - end - return result -end -local function plan(a, b) return session.stream_swap_plan({ windows = { windows[a], windows[b] } }) end -local before = positions() -assert(before.a == "two" and before.b == "one" and before.c == "three" and before.d == "four") -local exchange = plan(1, 2) -assert(exchange.windows[1].before_id == "id-two" and exchange.windows[1].zone_id == "id-one", "swap plan includes both stable zone identities") -calls = {} -session.stream_swap_apply(exchange) -session.stream_swap_apply(exchange) -- lost reply: absolute replay, no toggle -local after = positions() -assert(laptop.zone_id == "id-one", "compositor source identity follows the swap immediately") -assert(after.a == "one" and after.b == "two" and after.c == "three" and after.d == "four", - "source/local swap preserves unrelated windows and handles uncapped fill") -windows[5] = { address = "extra", pid = 5, stable_id = 5, workspace = ws, mapped = true } -assert(positions().extra ~= "one" and positions().extra ~= "four", "new local windows avoid both source reservations") -windows[5] = nil -for _, call in ipairs(calls) do assert(call.kind == "resize", "swap only refreshes layout; never focuses, moves, or restarts") end -session.stream_swap_apply(plan(2, 3)) -after = positions() -assert(after.b == "three" and after.c == "two" and after.a == "one", "displaced local windows remain swappable") -exchange = plan(1, 4) -session.stream_swap_apply(exchange) -after = positions() -assert(after.a == "four" and after.d == "one", "two source reservations exchange atomically") -session.stream_swap_cancel(exchange) -after = positions() -assert(laptop.zone_id == "id-one", "cancel restores the original source zone identity") -assert(after.a == "one" and after.d == "four", "cancel restores both sides of an uncertain exchange") -exchange = plan(1, 2) -windows[2].stable_id = 99 -ok = pcall(session.stream_swap_apply, exchange) -assert(not ok and positions().a == "one", "stale target rejects the whole swap before changing reservations") -windows[2].stable_id = 2 -windows[2].fullscreen = 2 -ok = pcall(session.stream_swap_apply, exchange) -assert(not ok and positions().a == "one", "fullscreen change rejects the whole swap") -windows[2].fullscreen = 0 -local destination = engine.live.test.compiled.leaf_opts[exchange.windows[1].zone] -local original_id = destination.id -destination.id = "replacement-zone" -ok = pcall(session.stream_swap_apply, exchange) -assert(not ok and positions().a == "one", "reusing a zone name cannot replay a stale swap") -destination.id = original_id -session.stream_swap_apply(exchange) -session.stream_swap_cancel(exchange) -assert(positions().b == "three" and engine.state.test.exclusive_pins.b, "cancel restores the previous local swap pin") --- A swapped local pin survives session placement and is still releasable. -session.place({ address = "b", layout = "lua:test", saved = { workspace = "1", pin = "three", pin_exclusive = true } }) -assert(engine.state.test.exclusive_pins.b) -engine.handle_msg(engine.live.test.compiled, engine.state.test, "unpin", windows[2]) -assert(not engine.state.test.exclusive_pins.b and not engine.state.test.pins.b) -print("stream placement: all checks passed") diff --git a/test/stream.py b/test/stream.py deleted file mode 100644 index 73eb182..0000000 --- a/test/stream.py +++ /dev/null @@ -1,925 +0,0 @@ -"""Failure-oriented stream lifecycle checks; no real hosts or compositor.""" -import copy -import fcntl -from concurrent.futures import ThreadPoolExecutor, TimeoutError -import json -import os -from pathlib import Path -import sys -import tempfile -import subprocess -import unittest -from unittest.mock import Mock, patch - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "stream")) -import controller as s -import streams as integration -import mac_display - -IDENTITY = "12345678-1234-1234-1234-123456789ABC" -MODE = {"resolution": "1920x1080", "hidpi": True, "refresh": 60} -OLD = {"resolution": "2560x1440", "hidpi": False, "refresh": 60} -BUILTIN = "AAAAAAAA-1234-1234-1234-123456789ABC" -PANEL = {"resolution": "1728x1117", "hidpi": True, "refresh": "ProMotion"} - - -def computer(adapter="external"): - display = {"adapter": adapter} - if adapter != "external": - display.update(uuid=IDENTITY, mode=MODE, require_ac=True) - return {"host": "laptop.example", "pairing_uuid": IDENTITY, "title": "Laptop - Moonlight", - "ssh": {"user": "tester"}, "profiles": {"desktop": {"stream_resolution": "2560x1440", "display": display}}} - - -class Host: - def __init__(self, adapter="betterdisplay"): - self.display = computer(adapter)["profiles"]["desktop"]["display"] - self.current = {"mode": copy.deepcopy(OLD), "output": "1"} - self.calls = [] - self.error = None - self.fail_field = None - self.before_change = None - self.ac_power = True - self.topology = {"lid_closed": True, "display_id": "5"} - - def probe(self, pairing=True): - if self.error: - raise ValueError(self.error) - return {"current": copy.deepcopy(self.current), "identity": {"UUID": IDENTITY, "displayID": self.topology["display_id"]}, - "ac_power": self.ac_power, "modes": [MODE, OLD], "topology": copy.deepcopy(self.topology)} - - def remote(self, operation): - return self.probe(False) - - def change(self, field, expected, value): - if self.before_change: - self.before_change(field, expected, value) - if field == self.fail_field: - raise ValueError("injected host failure") - if self.current[field] != expected: - raise ValueError("restore-conflict") - self.calls.append((field, copy.deepcopy(value))) - self.current[field] = copy.deepcopy(value) - - -class MainHost(Host): - """Two physical panels with independent modes and one Sunshine output.""" - def __init__(self): - super().__init__() - self.display["follow_main"] = True - self.identity = IDENTITY - self.panels = {IDENTITY: copy.deepcopy(OLD), BUILTIN: copy.deepcopy(PANEL)} - self.available = {IDENTITY, BUILTIN} - self.refreshes = 0 - - def switch(self, identity, lid_closed): - self.panels[self.identity] = copy.deepcopy(self.current["mode"]) - self.identity = identity - self.current["mode"] = copy.deepcopy(self.panels[identity]) - self.topology = {"lid_closed": lid_closed, "display_id": "5" if identity == IDENTITY else "1", - "display_uuid": identity} - - def probe(self, pairing=True): - observed = super().probe(pairing) - observed["identity"]["UUID"] = self.identity - return observed - - def remote(self, operation, **values): - if operation == "refresh": - if values["expected_identity"] != self.identity or values["expected"] != self.current: - raise ValueError("display-topology-changed") - self.refreshes += 1 - return {} - return self.probe(False) - - def change(self, field, expected, value, **guards): - if guards.get("expected_identity", self.identity) != self.identity: - raise ValueError("display-topology-changed") - super().change(field, expected, value) - self.panels[self.identity] = copy.deepcopy(self.current["mode"]) - - def for_display(self, identity): - owner = self - class Pinned: - def remote(self, operation): - if identity not in owner.available: - raise ValueError("display-missing") - result = owner.probe(False) - result["current"]["mode"] = copy.deepcopy(owner.panels[identity]) - result["identity"] = {"UUID": identity, "displayID": "5" if identity == IDENTITY else "1"} - result["topology"].update(display_uuid=identity, display_id=result["identity"]["displayID"]) - return result - - def change(self, field, expected, value): - assert field == "mode" - if owner.panels[identity] != expected: - raise ValueError("restore-conflict") - owner.panels[identity] = copy.deepcopy(value) - if owner.identity == identity: - owner.current["mode"] = copy.deepcopy(value) - owner.calls.append(("restore-mode", identity)) - return Pinned() - - -class Compositor: - instance = "one" - - def __init__(self): - self.desktop = {"windows": [], "streams": [], "workspace": "1", "layouts": {}, "monitors": [], - "workspaces": [{"selector": "1", "layout": "lua:quad", "visible": False}]} - self.calls = [] - self.invalid = False - self.swap_plan = None - self.swap_error = None - self.swap_timeout = False - - def snapshot(self): - return copy.deepcopy(self.desktop) - - def call(self, method, args): - self.calls.append((method, copy.deepcopy(args))) - if method == "stream_check" and self.invalid: - raise ValueError("assignment-invalid: zone missing") - if method == "stream_assign": - self.desktop["streams"] = [r for r in self.desktop["streams"] if r["computer"] != args["computer"]] + [copy.deepcopy(args)] - if method == "stream_release": - self.desktop["streams"] = [r for r in self.desktop["streams"] if r["computer"] != args["computer"]] - if method == "stream_swap_plan": - return copy.deepcopy(self.swap_plan) - if method in ("stream_swap_apply", "stream_swap_cancel"): - if method == "stream_swap_apply" and self.swap_error: - raise RuntimeError(self.swap_error) - for w in args["windows"]: - for source in self.desktop["streams"]: - if source["computer"] == w.get("computer"): - source["zone"] = w["zone"] if method == "stream_swap_apply" else w["before"] - if method == "stream_swap_apply" and self.swap_timeout: - raise subprocess.TimeoutExpired("injected lost apply reply", 5) - return True - - -class Processes: - def __init__(self): - self.alive = None - self.count = 0 - self.log = {} - - def pid(self, record): - return self.alive - - def events(self, record): - return self.log - - def launch(self, record): - self.alive = 123 - self.count += 1 - - def stop(self, record, force=False): - self.alive = None - - -class StreamTests(unittest.TestCase): - def setUp(self): - self.directory = tempfile.TemporaryDirectory() - self.addCleanup(self.directory.cleanup) - self.root = Path(self.directory.name) - self.config = self.root / "computers.json" - self.config.write_text(json.dumps({"version": 1, "computers": {"laptop": computer()}})) - self.comp = Compositor() - self.proc = Processes() - self.host = Host("external") - self.now = 100 - self.ctl = self.controller() - - def controller(self): - return s.Controller(self.root, self.config, self.comp, self.proc, lambda *_: self.host, lambda: self.now) - - def connect(self): - return self.ctl.command({"command": "connect", "computer": "laptop", "profile": "desktop", "zone": "right"}) - - def tick(self, n=1): - for _ in range(n): - self.ctl.tick() - self.now += 1 - - def test_queued_disconnect_prevents_launch_and_retries(self): - accepted = self.connect() - self.assertEqual(accepted["observed"], "preflight") - self.assertTrue(accepted["operation"]) - r = self.ctl.command({"command": "disconnect", "computer": "laptop"}) - self.assertGreater(r["generation"], accepted["generation"]) - self.tick(8) - self.assertEqual(self.proc.count, 0) - self.assertEqual(self.ctl.records["laptop"]["observed"], "disconnected") - - def test_repeated_connect_is_idempotent_and_conflicting_assignment_rejected(self): - a, b = self.connect(), self.connect() - self.assertEqual(a["operation"], b["operation"]) - with self.assertRaisesRegex(ValueError, "already owned"): - self.ctl.command({"command": "connect", "computer": "laptop", "zone": "left"}) - self.tick(4) - self.connect() - self.assertEqual(self.proc.count, 1) - - def test_disconnect_in_each_connect_phase(self): - for n in range(4): - with self.subTest(phase=n): - self.connect() - self.tick(n) - self.ctl.command({"command": "disconnect", "computer": "laptop"}) - count = self.proc.count - self.tick(5) - self.assertIsNone(self.proc.alive) - self.assertEqual(self.proc.count, count) - - def test_restart_adopts_owned_process_and_does_not_relaunch(self): - self.connect() - self.tick(3) - self.ctl = self.controller() - self.tick(3) - self.assertEqual(self.proc.count, 1) - - def test_old_compositor_process_is_stopped_before_relaunch(self): - self.connect() - self.tick(3) - self.comp.instance = "two" - self.ctl = self.controller() - self.tick(6) - self.assertEqual(self.proc.count, 2) - - def test_only_final_title_and_owned_pid_qualify(self): - self.connect() - self.tick(3) - self.comp.desktop["windows"] = [{"address": "a", "pid": 999, "stable_id": 1, "class": s.CLASS, - "title": "Laptop - Moonlight", "workspace": "1"}] - self.tick() - self.assertNotIn("window", self.ctl.records["laptop"]) - self.comp.desktop["windows"][0].update(pid=123, title="Moonlight") - self.tick() - self.assertEqual(self.ctl.records["laptop"]["observed"], "startup-window") - self.comp.desktop["windows"][0]["title"] = "Laptop - Moonlight" - self.tick() - r = self.ctl.records["laptop"] - self.assertEqual(r["observed"], "window-ready") - self.assertFalse(any(m == "stream_focus" for m, _ in self.comp.calls)) - self.assertNotEqual(r["observed"], "streaming") - - def test_network_retry_is_bounded_and_reserves_zone(self): - self.host.error = "host-unreachable" - self.connect() - for _ in range(8): - self.tick() - self.now += 30 - r = self.ctl.records["laptop"] - self.assertEqual(r["attempts"], 3) - self.assertEqual(r["observed"], "needs-attention") - self.assertTrue(r["desired"]) - self.assertTrue(self.comp.desktop["streams"]) - self.assertEqual(self.proc.count, 0) - - def ready_swap(self): - self.connect() - self.tick(3) - self.comp.desktop["windows"] = [{"address": "a", "pid": 123, "stable_id": 1, "class": s.CLASS, - "title": "Laptop - Moonlight", "workspace": "1"}] - self.tick() - self.comp.swap_plan = {"workspace": "1", "layout": "lua:quad", "windows": [ - {"computer": "laptop", "address": "a", "pid": 123, "stable_id": 1, "before": "right", "zone": "left"}, - {"address": "b", "pid": 456, "stable_id": 2, "before": "left", "zone": "right"}]} - return {"command": "swap", "windows": [{"address": "a", "stable_id": 1}, {"address": "b", "stable_id": 2}]} - - def test_swap_persists_assignment_without_relaunch_or_host_changes(self): - request = self.ready_swap() - original = copy.deepcopy(self.ctl.records["laptop"]) - result = self.ctl.command(request) - self.assertTrue(result["swapped"]) - self.ctl = self.controller() - self.tick(3) - current = self.ctl.records["laptop"] - self.assertEqual(current["assignment"]["zone"], "left") - for field in ("generation", "token", "operation", "journal", "window", "settings"): - self.assertEqual(current.get(field), original.get(field), field) - self.assertEqual(self.proc.count, 1) - self.assertEqual(self.host.calls, []) - self.assertNotIn("swap", self.ctl.state) - self.assertFalse(any(m == "stream_focus" for m, _ in self.comp.calls)) - - def test_swap_lost_reply_replays_absolute_assignments_after_restart(self): - request = self.ready_swap() - self.comp.swap_timeout = True - with self.assertRaises(subprocess.TimeoutExpired): - self.ctl.command(request) - disk = json.loads((self.root / "state.json").read_text()) - self.assertIn("swap", disk) - self.assertEqual(disk["computers"]["laptop"]["assignment"]["zone"], "right") - self.assertEqual(self.comp.desktop["streams"][0]["zone"], "left") - self.comp.swap_timeout = False - self.ctl = self.controller() - self.tick(2) - self.assertEqual(self.ctl.records["laptop"]["assignment"]["zone"], "left") - self.assertEqual(self.proc.count, 1) - - def test_failed_swap_rolls_back_without_stopping_stream(self): - request = self.ready_swap() - self.comp.swap_timeout = True - with self.assertRaises(subprocess.TimeoutExpired): - self.ctl.command(request) - self.comp.swap_timeout = False - self.comp.swap_error = "swap unavailable: target has closed" - with self.assertRaisesRegex(RuntimeError, "swap unavailable"): - self.ctl.finish_swap() - self.tick(2) - self.assertNotIn("swap", self.ctl.state) - self.assertEqual(self.ctl.records["laptop"]["assignment"]["zone"], "right") - self.assertEqual(self.comp.desktop["streams"][0]["zone"], "right") - self.assertEqual(self.proc.count, 1) - self.assertEqual(self.proc.alive, 123) - - def test_swap_rejects_stale_owner_before_journaling(self): - request = self.ready_swap() - self.comp.swap_plan["windows"][0]["stable_id"] = 999 - with self.assertRaisesRegex(ValueError, "identity changed"): - self.ctl.command(request) - self.assertNotIn("swap", self.ctl.state) - self.assertFalse(any(m == "stream_swap_apply" for m, _ in self.comp.calls)) - - def test_two_stream_swap_updates_both_saved_assignments(self): - request = self.ready_swap() - other = copy.deepcopy(self.ctl.records["laptop"]) - other.update(computer="other", window={"address": "b", "pid": 456, "stable_id": 2}) - other["assignment"]["zone"] = "left" - self.ctl.records["other"] = other - self.comp.swap_plan["windows"][1]["computer"] = "other" - self.ctl.command(request) - disk = json.loads((self.root / "state.json").read_text())["computers"] - self.assertEqual(disk["laptop"]["assignment"]["zone"], "left") - self.assertEqual(disk["other"]["assignment"]["zone"], "right") - - def test_pending_swap_cannot_replay_addresses_into_new_compositor(self): - request = self.ready_swap() - self.comp.swap_timeout = True - with self.assertRaises(subprocess.TimeoutExpired): - self.ctl.command(request) - self.comp.calls.clear() - self.comp.instance = "two" - self.ctl = self.controller() - self.ctl.finish_swap() - self.assertNotIn("swap", self.ctl.state) - self.assertEqual(self.ctl.records["laptop"]["assignment"]["zone"], "right") - self.assertFalse(any(m == "stream_swap_apply" for m, _ in self.comp.calls)) - - def test_pairing_failure_is_not_retried(self): - self.host.error = "pairing-required" - self.connect() - self.tick(5) - self.assertEqual(self.ctl.records["laptop"]["attempts"], 0) - - def test_window_close_cancels_intent_but_unknown_exit_does_not_retry(self): - for evidence, desired in (({"quit": True}, False), ({"closed": True, "quit": True}, False), ({"closed": True}, True)): - self.connect() - self.tick(3) - self.proc.alive = None - self.proc.log = evidence - self.tick(4) - self.assertEqual(self.ctl.records["laptop"]["desired"], desired) - count = self.proc.count - self.now += 100 - self.tick(2) - self.assertEqual(self.proc.count, count) - if desired: - self.ctl.command({"command": "disconnect", "computer": "laptop"}) - self.tick(2) - self.proc.log = {} - - def test_missing_zone_does_not_reassign_and_stops_stream(self): - self.connect() - self.tick(3) - self.comp.invalid = True - self.tick(2) - r = self.ctl.records["laptop"] - self.assertEqual(r["assignment"]["zone"], "right") - self.assertIn("assignment-invalid", r["error"]) - self.assertIsNone(self.proc.alive) - - def test_session_restore_does_not_resurrect_disconnect(self): - self.connect() - self.ctl.command({"command": "disconnect", "computer": "laptop"}) - self.tick(2) - self.ctl.command({"command": "session-restore", "sources": [{"computer": "laptop", "zone": "right", "workspace": "1", "profile": "desktop"}]}) - self.tick(3) - self.assertFalse(self.ctl.records["laptop"]["desired"]) - self.assertEqual(self.proc.count, 0) - - def test_session_restore_retains_unknown_source_as_pending(self): - source = {"computer": "unknown", "profile": "desktop", "workspace": "1", "zone": "right", "layout": "lua:quad"} - self.ctl.command({"command": "session-restore", "sources": [source]}) - self.tick(3) - r = self.ctl.records["unknown"] - self.assertTrue(r["desired"]) - self.assertEqual(r["observed"], "needs-attention") - self.assertEqual(r["assignment"]["zone"], "right") - self.assertEqual(self.proc.count, 0) - - def test_restart_waits_for_local_recovery_to_create_workspace(self): - self.connect() - self.tick(3) - self.comp.instance = "two" - self.comp.desktop["workspaces"] = [] - self.ctl = self.controller() - self.tick(8) - self.assertEqual(self.proc.count, 1) - self.comp.desktop["workspaces"] = [{"selector": "1", "layout": "lua:quad"}] - self.tick(5) - self.assertEqual(self.proc.count, 2) - - def test_pending_restore_and_explicit_release(self): - self.connect() - r = self.ctl.records["laptop"] - r["journal"] = {"mode": {"original": OLD, "applied": MODE, "phase": "applied"}} - self.host.error = "host-unreachable" - self.ctl.command({"command": "disconnect", "computer": "laptop"}) - self.tick(2) - self.assertEqual(r["observed"], "restore-pending") - self.assertFalse(self.comp.desktop["streams"]) - with self.assertRaisesRegex(ValueError, "keep-host-settings"): - self.ctl.command({"command": "release", "computer": "laptop"}) - self.ctl.command({"command": "release", "computer": "laptop", "keep_host_settings": True}) - self.assertFalse(r["journal"]) - - def test_original_and_intent_are_durable_before_every_write(self): - host = Host() - record = {} - saved = [] - def persist(): - saved.append(copy.deepcopy(record)) - def before(field, expected, value): - self.assertEqual(saved[-1]["journal"][field]["original"], expected) - self.assertEqual(saved[-1]["journal"][field]["applied"], value) - host.before_change = before - s.prepare(record, host, persist) - self.assertEqual(record["journal"]["mode"]["original"], OLD) - host.before_change = None - s.prepare(record, host, persist) # Reconnect keeps the first baseline. - self.assertEqual(len(host.calls), 2) - self.assertTrue(s.restore(record, host, persist)) - self.assertEqual(host.current["mode"], OLD) - - def test_partial_prepare_rolls_back_only_changes_that_happened(self): - host, record = Host(), {} - host.fail_field = "mode" - with self.assertRaises(ValueError): - s.prepare(record, host, lambda: None) - host.fail_field = None - self.assertTrue(s.restore(record, host, lambda: None)) - self.assertEqual(host.current, {"mode": OLD, "output": "1"}) - self.assertEqual([field for field, _ in host.calls], ["output", "output"]) - - def test_crash_after_apply_recovers_intent_without_rebaselining(self): - host, record = Host(), {} - record["journal"] = {"mode": {"original": OLD, "applied": MODE, "phase": "intent"}} - host.current = {"mode": copy.deepcopy(MODE), "output": "5"} - s.prepare(record, host, lambda: None) - self.assertEqual(record["journal"]["mode"]["original"], OLD) - self.assertFalse(host.calls) - self.assertTrue(s.restore(record, host, lambda: None)) - - def test_manual_changes_preserved_and_conflict_persisted(self): - host, record = Host(), {} - s.prepare(record, host, lambda: None) - manual = {"resolution": "1280x720", "hidpi": True, "refresh": 60} - host.current["mode"] = manual - self.assertFalse(s.restore(record, host, lambda: None)) - self.assertEqual(host.current["mode"], manual) - self.assertEqual(record["journal"]["mode"]["phase"], "conflict") - self.assertEqual(host.current["output"], "1") - - def ready_mac(self): - self.config.write_text(json.dumps({"version": 1, "computers": {"laptop": computer("betterdisplay")}})) - self.host = Host() - self.connect() - self.tick(3) - self.comp.desktop["windows"] = [{"address": "a", "pid": 123, "stable_id": 1, "class": s.CLASS, - "title": "Laptop - Moonlight", "workspace": "1"}] - self.tick() - return self.ctl.records["laptop"] - - def test_lid_change_reapplies_mode_without_replacing_original_baseline(self): - r = self.ready_mac() - baseline, assignment = copy.deepcopy(r["journal"]), copy.deepcopy(r["assignment"]) - self.host.topology["lid_closed"] = False - self.host.current["mode"] = {"resolution": "6144x2560", "hidpi": False, "refresh": 60} - self.now += 6 - self.tick() - self.assertEqual(r["phase"], "reconnect-stop") - self.assertEqual(r["journal"], baseline) - self.ctl = self.controller() # Pending recovery must survive controller restart. - r = self.ctl.records["laptop"] - self.proc.alive = None - self.proc.log = {"closed": True, "quit": True, "terminated": 0} - self.comp.desktop["windows"] = [] - self.tick(3) - self.assertEqual(r["phase"], "launch") - self.assertEqual(self.host.current["mode"], MODE) - self.assertEqual(r["journal"], baseline) - self.assertEqual(r["assignment"], assignment) - self.assertEqual(r["mac_topology"], self.host.topology) - self.assertNotIn("display_recovery", r) - - def test_mode_change_without_lid_transition_is_preserved(self): - r = self.ready_mac() - manual = {"resolution": "1280x720", "hidpi": False, "refresh": 60} - self.host.current["mode"] = manual.copy() - calls = len(self.host.calls) - self.now += 6 - self.tick() - self.assertEqual(self.host.current["mode"], manual) - self.assertEqual(len(self.host.calls), calls) - self.assertEqual(r["observed"], "degraded") - self.assertNotIn("display_recovery", r) - - def test_lid_recovery_tracks_new_numeric_id_for_same_display(self): - host, record = Host(), {} - s.prepare(record, host, lambda: None) - host.topology = {"lid_closed": False, "display_id": "7"} - record["display_recovery"] = {"current": copy.deepcopy(host.current), "topology": copy.deepcopy(host.topology)} - s.prepare(record, host, lambda: None) - self.assertEqual(host.current["output"], "7") - self.assertEqual(record["journal"]["output"]["original"], "1") - self.assertEqual(record["journal"]["output"]["applied"], "7") - self.assertTrue(s.restore(record, host, lambda: None)) - self.assertEqual(host.current["output"], "1") - - def test_lid_recovery_rejects_changes_after_it_was_requested(self): - for change in ("mode", "topology"): - with self.subTest(change=change): - host, record = Host(), {} - s.prepare(record, host, lambda: None) - host.topology["lid_closed"] = False - host.current["mode"] = {"resolution": "6144x2560", "hidpi": False, "refresh": 60} - record["display_recovery"] = {"current": copy.deepcopy(host.current), "topology": copy.deepcopy(host.topology)} - if change == "mode": - host.current["mode"] = {"resolution": "1280x720", "hidpi": False, "refresh": 60} - else: - host.topology["lid_closed"] = True - calls = len(host.calls) - with self.assertRaises(ValueError): - s.prepare(record, host, lambda: None) - self.assertEqual(len(host.calls), calls) - - def test_disconnect_cancels_pending_lid_reconnect(self): - r = self.ready_mac() - self.host.topology["lid_closed"] = False - self.host.current["mode"] = {"resolution": "6144x2560", "hidpi": False, "refresh": 60} - self.now += 6 - self.tick() - self.assertEqual(r["phase"], "reconnect-stop") - self.ctl.command({"command": "disconnect", "computer": "laptop"}) - self.tick(3) - self.assertFalse(r["desired"]) - self.assertEqual(r["observed"], "disconnected") - self.assertEqual(r["journal"], {}) - self.assertEqual(self.host.current["mode"], OLD) - self.assertEqual(self.proc.count, 1) - - def recover_main(self, host, record): - health = host.probe(False) - record["display_recovery"] = {"current": health["current"], "topology": health["topology"]} - s.prepare(record, host, lambda: None) - - def test_main_screen_switch_restores_only_the_old_panel(self): - host, record = MainHost(), {} - s.prepare(record, host, lambda: None) - self.assertEqual(record["journal"]["mode"]["display_uuid"], IDENTITY) - host.switch(BUILTIN, False) - self.recover_main(host, record) - self.assertEqual(host.current, {"mode": PANEL, "output": "1"}) - self.assertEqual(host.panels[IDENTITY], OLD) - self.assertNotIn("mode", record["journal"]) - self.assertEqual(record["journal"]["output"]["original"], "1") - # Simulate the durable record being loaded after a controller restart. - record = json.loads(json.dumps(record)) - host.switch(IDENTITY, True) - self.recover_main(host, record) - self.assertEqual(host.current, {"mode": MODE, "output": "5"}) - self.assertTrue(s.restore(record, host, lambda: None)) - self.assertEqual(host.current, {"mode": OLD, "output": "1"}) - self.assertEqual(host.panels[BUILTIN], PANEL) - - def test_unplugged_mode_restore_does_not_target_the_builtin_panel(self): - host, record = MainHost(), {} - s.prepare(record, host, lambda: None) - host.switch(BUILTIN, False) - host.available.remove(IDENTITY) - self.recover_main(host, record) - self.assertEqual(host.current, {"mode": PANEL, "output": "1"}) - self.assertEqual(record["journal"]["mode"]["phase"], "unavailable") - self.assertFalse(s.restore(record, host, lambda: None)) - host.available.add(IDENTITY) - self.assertTrue(s.restore(record, host, lambda: None)) - self.assertEqual(host.panels[IDENTITY], OLD) - self.assertEqual(host.panels[BUILTIN], PANEL) - - def test_main_screen_does_not_require_or_apply_external_mode(self): - host = MainHost() - host.switch(BUILTIN, False) - profile = computer("betterdisplay")["profiles"]["desktop"] - profile["display"]["follow_main"] = True - native = s.Host(computer("betterdisplay"), profile) - native.remote = lambda *_: {**host.probe(False), "modes": [PANEL]} - native.probe(pairing=False) # A 16:9 mode is absent from this panel. - record = {} - s.prepare(record, host, lambda: None) - self.assertEqual(host.current["mode"], PANEL) - self.assertEqual(host.calls, []) - self.assertEqual(host.refreshes, 1) - self.assertTrue(s.same_setting("mode", PANEL, PANEL.copy())) - self.assertFalse(s.same_setting("mode", PANEL, {**PANEL, "refresh": 60})) - - def test_main_change_reconnects_same_zone_and_disconnect_cancels_it(self): - c = computer("betterdisplay") - c["profiles"]["desktop"]["display"]["follow_main"] = True - self.config.write_text(json.dumps({"version": 1, "computers": {"laptop": c}})) - self.host = MainHost() - self.connect() - self.tick(3) - self.comp.desktop["windows"] = [{"address": "a", "pid": 123, "stable_id": 1, "class": s.CLASS, - "title": "Laptop - Moonlight", "workspace": "1"}] - self.tick() - r = self.ctl.records["laptop"] - assignment = copy.deepcopy(r["assignment"]) - # Main display can change even if the lid has not moved. - self.host.switch(BUILTIN, True) - self.now += 6 - self.tick() - self.assertEqual(r["phase"], "reconnect-stop") - self.assertEqual(r["assignment"], assignment) - self.ctl = self.controller() - self.ctl.command({"command": "disconnect", "computer": "laptop"}) - self.tick(3) - self.assertEqual(self.proc.count, 1) - self.assertEqual(self.ctl.records["laptop"]["journal"], {}) - self.assertEqual(self.host.panels[BUILTIN], PANEL) - - def test_main_screen_switch_replays_after_output_write_crash(self): - host, record = MainHost(), {} - s.prepare(record, host, lambda: None) - host.switch(BUILTIN, False) - health = host.probe(False) - record["display_recovery"] = {"current": health["current"], "topology": health["topology"]} - # Crash after the output write, before the readback/phase update. - saved = [] - def persist(): - saved[:] = [copy.deepcopy(record)] - change = host.change - def crash(field, expected, value, **guards): - change(field, expected, value, **guards) - if field == "output": - raise RuntimeError("crash") - host.change = crash - with self.assertRaisesRegex(RuntimeError, "crash"): - s.prepare(record, host, persist) - host.change = change - record = saved[0] - s.prepare(record, host, lambda: None) - self.assertEqual(record["journal"]["output"]["original"], "1") - self.assertEqual(host.current, {"mode": PANEL, "output": "1"}) - self.assertTrue(s.restore(record, host, lambda: None)) - - def test_native_profile_needs_no_betterdisplay_uuid_or_mode(self): - c = computer() - c["platform"] = "macos" - c["profiles"]["desktop"]["display"] = {"adapter": "macos"} - self.config.write_text(json.dumps({"version": 1, "computers": {"laptop": c}})) - s.configuration(self.config) - h = s.Host(c, c["profiles"]["desktop"]) - observed = {"identity": {"UUID": BUILTIN, "displayID": "1"}, "current": {"mode": PANEL, "output": "1"}, - "modes": [], "ac_power": True, "topology": {"lid_closed": False, "display_id": "1"}} - h.remote = Mock(return_value=observed) - record = {} - s.prepare(record, h, lambda: None) - self.assertEqual(record["journal"], {}) - self.assertFalse(any(call.args[0] == "change" for call in h.remote.call_args_list)) - self.assertEqual(h.remote.call_args.args[0], "refresh") - - def test_native_adapter_never_invokes_betterdisplay_or_changes_mode(self): - directory = self.root / ".config/sunshine" - directory.mkdir(parents=True) - (directory / "sunshine_state.json").write_text(json.dumps({"root": {"uniqueid": IDENTITY}})) - (directory / "sunshine.conf").write_text("output_name = 1\n") - request = {"adapter": "macos", "pairing_uuid": IDENTITY, "operation": "probe"} - def run(argv): - self.assertNotIn(mac_display.BETTER, argv) - return "AC Power" if argv[0] == "/usr/bin/pmset" else '"AppleClamshellState" = No' - with patch.object(Path, "home", return_value=self.root), \ - patch.object(mac_display, "native_display", return_value=({"UUID": BUILTIN, "displayID": "1"}, PANEL, "3456x2234")), \ - patch.object(mac_display, "run", side_effect=run), \ - patch.object(mac_display, "restart_sunshine") as restart: - result = mac_display.display(request) - self.assertEqual(result["current"]["mode"], PANEL) - self.assertEqual(result["render_resolution"], "3456x2234") - with self.assertRaisesRegex(ValueError, "preserves the host mode"): - mac_display.display({**request, "operation": "change", "field": "mode", "expected": PANEL, "value": MODE}) - restart.assert_not_called() - - def test_mac_main_identity_race_is_rejected_before_writing(self): - directory = self.root / ".config/sunshine" - directory.mkdir(parents=True) - (directory / "sunshine_state.json").write_text(json.dumps({"root": {"uniqueid": IDENTITY}})) - graphics = Mock() - graphics.CGMainDisplayID.return_value = 1 - with patch.object(Path, "home", return_value=self.root), \ - patch.object(mac_display.ctypes, "CDLL", return_value=graphics), \ - patch.object(mac_display, "run", return_value=json.dumps({"UUID": BUILTIN, "displayID": "1"})) as run, \ - patch.object(mac_display, "restart_sunshine") as restart: - with self.assertRaisesRegex(ValueError, "display-topology-changed"): - mac_display.display({"pairing_uuid": IDENTITY, "display_uuid": IDENTITY, "follow_main": True, - "expected_identity": IDENTITY, "operation": "change", "field": "mode", - "expected": OLD, "value": MODE}) - self.assertEqual(run.call_count, 1) - self.assertEqual(run.call_args.args[0][1], "get") - restart.assert_not_called() - - def test_nominal_refresh_tolerance_does_not_hide_other_mode_changes(self): - actual = {**MODE, "refresh": 59.95} - self.assertTrue(s.same_setting("mode", MODE, actual)) - self.assertFalse(s.same_setting("mode", MODE, {**actual, "refresh": 50})) - self.assertFalse(s.same_setting("mode", MODE, {**actual, "hidpi": False})) - host = Host() - host.current = {"mode": actual, "output": "5"} - record = {"journal": {"mode": {"original": OLD, "applied": MODE, "phase": "intent"}}} - s.prepare(record, host, lambda: None) - self.assertTrue(s.restore(record, host, lambda: None)) - - def test_invalid_configuration_rejected(self): - for edit in (lambda c: c.update(host="-oProxyCommand=evil"), - lambda c: c.update(pairing_uuid="localhost"), - lambda c: c["profiles"]["desktop"].update(stream_resolution="1920x1080;touch /tmp/bad")): - value = computer() - edit(value) - self.config.write_text(json.dumps({"version": 1, "computers": {"laptop": value}})) - with self.assertRaises(ValueError): - s.configuration(self.config) - - def test_job_stale_generation_cannot_exec(self): - self.connect() - r = self.ctl.records["laptop"] - r.update(desired=False, token="new") - self.ctl.persist() - job = self.root / "job.json" - s.atomic_json(job, {"state": str(self.root / "state.json"), "computer": "laptop", "token": "old"}) - with patch.object(os, "execvp") as execute: - s.launch_job(job) - execute.assert_not_called() - - def test_disconnect_cannot_miss_a_pid_being_published(self): - self.connect() - with (self.root / "laptop.gate").open("a") as gate, ThreadPoolExecutor(max_workers=1) as worker: - fcntl.flock(gate, fcntl.LOCK_EX) - command = worker.submit(self.ctl.command, {"command": "disconnect", "computer": "laptop"}) - try: - with self.assertRaises(TimeoutError): - command.result(timeout=.05) - self.proc.alive = 123 # The launch publishes its PID before exec. - finally: - fcntl.flock(gate, fcntl.LOCK_UN) - self.assertFalse(command.result(timeout=1)["desired"]) - self.tick(3) - self.assertIsNone(self.proc.alive) - self.assertEqual(self.proc.count, 0) - - def test_ssh_health_loss_keeps_view_but_power_loss_stops_it(self): - self.config.write_text(json.dumps({"version": 1, "computers": {"laptop": computer("betterdisplay")}})) - self.host = Host() - self.connect() - self.tick(3) - self.comp.desktop["windows"] = [{"address": "a", "pid": 123, "stable_id": 1, "class": s.CLASS, - "title": "Laptop - Moonlight", "workspace": "1"}] - self.host.error = "host-unreachable" - self.now += 31 - self.tick() - self.assertEqual(self.ctl.records["laptop"]["observed"], "degraded") - self.assertEqual(self.proc.alive, 123) - self.host.error = None - self.host.ac_power = False - self.now += 31 - self.tick(2) - self.assertIsNone(self.proc.alive) - self.assertIn("power-required", self.ctl.records["laptop"]["error"]) - - def test_visible_workspace_controls_only_owned_window_inhibition(self): - self.connect() - self.tick(3) - self.comp.desktop["windows"] = [{"address": "a", "pid": 123, "stable_id": 1, "class": s.CLASS, - "title": "Laptop - Moonlight", "workspace": "1"}] - self.tick() - self.assertIn(("stream_inhibit", {"computer": "laptop", "enabled": False}), self.comp.calls) - self.comp.desktop["workspaces"][0]["visible"] = True - self.tick() - self.assertIn(("stream_inhibit", {"computer": "laptop", "enabled": True}), self.comp.calls) - - def test_cli_never_requests_host_app_termination(self): - c = computer() - args = s.stream_argv(c, c["profiles"]["desktop"]) - self.assertIn("--no-quit-after", args) - self.assertNotIn("--quit-after", args) - self.assertEqual(args[-2], IDENTITY) - - def test_logs_never_persist_urls_or_secret_parameters(self): - self.assertIsNone(s.log_event("GET https://host/launch?rikey=SECRET&uniqueid=CLIENT")) - self.assertIsNone(s.log_event("Video stream is 2560x1440x60 https://host/?secret=SECRET")) - self.assertEqual(s.log_event("Video stream is 2560x1440x60 (format 0x100)"), - {"negotiated_video": {"width": 2560, "height": 1440, "fps": 60}}) - self.assertEqual(s.log_event("Quit event received"), {"quit": True}) - - def test_local_session_ignores_managed_windows_and_keeps_offline_source(self): - self.connect() - state = self.root / "hypertile/streams" - state.mkdir(parents=True) - s.atomic_json(state / "state.json", self.ctl.state) - desktop = self.comp.snapshot() - desktop["windows"] = [{"address": "local"}, {"address": "remote", "stream": "laptop"}] - with patch.dict(os.environ, XDG_STATE_HOME=str(self.root)): - captured = integration.capture(desktop) - self.assertEqual([w["address"] for w in captured["windows"]], ["local"]) - self.assertEqual(captured["streams"][0]["computer"], "laptop") - - def test_offline_remote_submission_does_not_block_local_recovery(self): - from service import Recovery - desktop = self.comp.snapshot() - desktop["streams"] = [{"computer": "laptop", "profile": "desktop", "zone": "right", "workspace": "1", "layout": "lua:quad"}] - record = {"version": 1, "instance": "old", "desktop": desktop} - with patch.object(integration, "restore", return_value=["remote offline"]): - recovery = Recovery(record, self.comp, object(), 0, lambda _: None) - self.assertEqual(recovery.tick(1), "restoring") - self.assertEqual(recovery.tick(4), "complete") - self.assertIn("remote offline", recovery.report()["limitations"]) - self.assertEqual(record["desktop"]["streams"], desktop["streams"]) - - def test_mac_host_identity_checked_before_display_reads_or_changes(self): - directory = self.root / ".config/sunshine" - directory.mkdir(parents=True) - (directory / "sunshine_state.json").write_text(json.dumps({"root": {"uniqueid": "other-host"}})) - with patch.object(Path, "home", return_value=self.root), patch.object(mac_display, "run") as run: - with self.assertRaisesRegex(ValueError, "host-identity-mismatch"): - mac_display.display({"pairing_uuid": IDENTITY, "display_uuid": IDENTITY, "operation": "probe"}) - run.assert_not_called() - - def test_mac_uuid_queries_exclude_default_display_group(self): - directory = self.root / ".config/sunshine" - directory.mkdir(parents=True) - (directory / "sunshine_state.json").write_text(json.dumps({"root": {"uniqueid": IDENTITY}})) - # Stop just after resolving the identifier; this assertion prevents the - # live BetterDisplay behavior where UUID-only queries include a group. - def run(argv): - self.assertIn("-type=Display", argv) - self.assertIn("-UUID=" + IDENTITY, argv) - raise ValueError("sentinel") - with patch.object(Path, "home", return_value=self.root), patch.object(mac_display, "run", side_effect=run), \ - patch.object(mac_display.ctypes, "CDLL", return_value=Mock()): - with self.assertRaisesRegex(ValueError, "display-missing"): - mac_display.display({"pairing_uuid": IDENTITY, "display_uuid": IDENTITY, "operation": "probe"}) - - def test_mac_mode_changes_refresh_sunshine_cached_pointer_scale(self): - directory = self.root / ".config/sunshine" - directory.mkdir(parents=True) - (directory / "sunshine_state.json").write_text(json.dumps({"root": {"uniqueid": IDENTITY}})) - (directory / "sunshine.conf").write_text("output_name = 4\n") - for hidpi in (True, False): - with self.subTest(original_hidpi=hidpi): - mode = {"resolution": "1920x1080", "hidpi": hidpi, "refresh": 60} - before = mode.copy() - target = {**mode, "hidpi": not hidpi} - sunshine = {"running": True, "scale": .5 if hidpi else 1, "restarts": 0} - def command(argv): - if argv[0] == "/usr/bin/open": - sunshine.update(running=True, scale=.5 if mode["hidpi"] else 1, - restarts=sunshine["restarts"] + 1) - return "" - if argv[1] == "get": - return {"-identifiers": json.dumps({"UUID": IDENTITY, "displayID": "4"}), - "-resolution": mode["resolution"], "-hiDPI": "on" if mode["hidpi"] else "off", - "-refreshRate": str(mode["refresh"]) + "Hz"}[argv[-1]] - values = dict(arg.split("=", 1) for arg in argv[2:]) - mode.update(resolution=values["-resolution"], hidpi=values["-hiDPI"] == "on", - refresh=float(values["-refreshRate"])) - return "" - def process(argv, **kwargs): - if argv[0] == "/usr/bin/pkill": - sunshine["running"] = False - return subprocess.CompletedProcess(argv, 0 if sunshine["running"] else 1) - graphics = Mock() - graphics.CGDisplayIsActive.return_value = 1 - with patch.object(Path, "home", return_value=self.root), \ - patch.object(mac_display.ctypes, "CDLL", return_value=graphics), \ - patch.object(mac_display, "run", side_effect=command), \ - patch.object(mac_display.subprocess, "run", side_effect=process): - mac_display.display({"pairing_uuid": IDENTITY, "display_uuid": IDENTITY, "operation": "change", - "field": "mode", "expected": before, "value": target}) - self.assertEqual(mode, target) - self.assertEqual(sunshine["scale"], .5 if target["hidpi"] else 1) - self.assertEqual(sunshine["restarts"], 1) - self.assertEqual((directory / "sunshine.conf").read_text(), "output_name = 4\n") - - -if __name__ == "__main__": - unittest.main() diff --git a/test/swap.lua b/test/swap.lua new file mode 100644 index 0000000..fa3ce8e --- /dev/null +++ b/test/swap.lua @@ -0,0 +1,31 @@ +-- Generic pinned swaps preserve identities and validate both sides atomically. +package.path = './?.lua;' .. package.path +local engine = require('hypertile') +engine.provider('test', {columns={{name='left', id='a'}, {name='right', id='b'}}}) +local ws = {id=1, name='1', tiled_layout='lua:test'} +local windows = { + {address='a', stable_id=1, pid=11, mapped=true, workspace=ws, class='editor'}, + {address='b', stable_id=2, pid=22, mapped=true, workspace=ws, class='com.moonlight_stream.Moonlight'} +} +local calls = {} +hl = {get_windows=function() return windows end, get_workspaces=function() return {ws} end, + dsp={window={resize=function(a) return a end}}, dispatch=function(a) calls[#calls+1]=a end} +local session = require('hypertile-session') +local live = engine.live.test +live.orders['1'] = {'a','b'} +live.state.pins.a = 'left' +live.state.exclusive_pins = {a=true} +local plan = session.swap_plan({windows=windows}) +windows[2].pid = 99 +assert(not pcall(session.swap_apply, plan)) +assert(live.state.pins.a == 'left' and not live.state.pins.b, 'stale identity cannot partially swap') +windows[2].pid = 22 +session.swap_apply(plan) +assert(live.state.pins.a == 'right' and live.state.pins.b == 'left') +session.swap_apply(plan) +assert(live.state.pins.a == 'right' and live.state.pins.b == 'left', 'replay is absolute') +assert(live.state.exclusive_pins.a and live.state.exclusive_pins.b) +live.state.scene_empty = {['1']={right=true}} +assert(not pcall(session.swap_apply, plan), 'cannot occupy an Empty zone') +assert(not session.stream_assign and not session.stream_launch, 'compositor owns no remote lifecycle') +print('generic pinned swaps: all checks passed') diff --git a/test/upgrade.py b/test/upgrade.py new file mode 100644 index 0000000..42b159c --- /dev/null +++ b/test/upgrade.py @@ -0,0 +1,42 @@ +"""Upgrade preserves unresolved host recovery and removes only owned runtime files.""" +import json +from pathlib import Path +import sys +import tempfile +import unittest +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'session')) +from upgrade import check_legacy, cleanup, obsolete + +class UpgradeTests(unittest.TestCase): + def test_pending_or_corrupt_state_refuses_retirement(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + path = root / 'streams/state.json' + path.parent.mkdir() + for value in ({'version': 1, 'computers': {'mac': {'desired': True}}}, + {'version': 1, 'computers': {'mac': {'desired': False, 'journal': {'output': {}}}}}, + {'version': 99, 'computers': {}}): + path.write_text(json.dumps(value)) + with self.assertRaises(ValueError): check_legacy(root) + path.write_text('not JSON') + with self.assertRaises(ValueError): check_legacy(root) + path.write_text(json.dumps({'version': 1, 'computers': {'mac': {'desired': False, 'journal': {}}}})) + check_legacy(root) + def test_cleanup_preserves_user_files_and_new_scene_modules(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + binary, data = root / 'bin', root / 'data' + for path in obsolete(binary, data): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('legacy') + preserved = [data / 'hypertile/stream/custom.py', data / 'hypertile/scenes/scenes.py', + data / 'hypertile/streams/state.json', binary / 'remote-desktops'] + for path in preserved: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('keep') + cleanup(binary, data) + cleanup(binary, data) + self.assertTrue(all(not p.exists() for p in obsolete(binary, data))) + self.assertTrue(all(p.read_text() == 'keep' for p in preserved)) + +if __name__ == '__main__': unittest.main() diff --git a/test/windows_display.py b/test/windows_display.py deleted file mode 100644 index 1c0452a..0000000 --- a/test/windows_display.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Failure and cancellation tests for the Windows display ownership boundary.""" -import copy -import json -from pathlib import Path -import subprocess -import sys -import unittest -from unittest.mock import patch -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'stream')) -import windows_display as w -import controller - -DEVICE = r'\\?\DISPLAY#MTT1337#virtual' -PAIR = '12345678-1234-1234-1234-123456789abc' - -class Host: - display = {'adapter': 'windows', 'device_id': DEVICE} - def __init__(self): - self.calls = [] - self.result = {'phase': 'idle', 'owner': '', 'active': [{'id': 'internal'}]} - self.fail = None - def remote(self, operation, **values): - self.calls.append((operation, values.copy())) - if self.fail: - raise self.fail - if operation == 'prepare': - self.result = {'owner': values['owner'], 'phase': 'preparing', 'active': [{'id': DEVICE}]} - if operation == 'restore': - self.result['phase'] = 'restore-pending' - return copy.deepcopy(self.result) - -class WindowsTests(unittest.TestCase): - def setUp(self): - self.record = {} - self.host = Host() - self.saved = [] - def persist(self): - self.saved.append(copy.deepcopy(self.record)) - def test_reconnect_keeps_owner_and_advances_sequence(self): - w.prepare(self.record, self.host, self.persist) - owner = self.record['journal']['windows']['owner'] - w.prepare(self.record, self.host, self.persist) - self.assertEqual(self.record['journal']['windows']['owner'], owner) - self.assertEqual(self.record['journal']['windows']['sequence'], 2) - def test_timeout_after_prepare_keeps_recovery_identity(self): - self.host.fail = subprocess.TimeoutExpired('ssh', 35) - with self.assertRaises(subprocess.TimeoutExpired): - w.prepare(self.record, self.host, self.persist) - self.assertEqual(self.saved[0]['journal']['windows']['phase'], 'intent') - self.host.fail = None - self.assertFalse(w.restore(self.record, self.host, self.persist)) - self.assertEqual(self.host.calls[-1][0], 'restore') - self.assertEqual(self.host.calls[-1][1]['sequence'], 2) - def test_pending_until_physical_readback_and_virtual_is_disabled(self): - w.prepare(self.record, self.host, self.persist) - self.assertFalse(w.restore(self.record, self.host, self.persist)) - self.host.result.update(phase='idle', active=[{'id': DEVICE}]) - self.assertFalse(w.restore(self.record, self.host, self.persist)) - self.host.result['active'] = [] - self.assertFalse(w.restore(self.record, self.host, self.persist)) - self.host.result['active'] = [{'id': 'internal'}] - self.assertTrue(w.restore(self.record, self.host, self.persist)) - self.assertEqual(self.record['journal'], {}) - self.assertEqual([c[0] for c in self.host.calls], ['prepare', 'restore', 'status', 'status', 'status']) - def test_failed_restore_ack_is_retried_as_restore_not_status(self): - w.prepare(self.record, self.host, self.persist) - self.host.fail = ValueError('lost reply') - with self.assertRaises(ValueError): - w.restore(self.record, self.host, self.persist) - self.host.fail = None - w.restore(self.record, self.host, self.persist) - self.assertEqual(self.host.calls[-1][0], 'restore') - self.assertEqual(self.host.calls[-1][1]['sequence'], 3) - def test_helper_error_prevents_false_restoration_success(self): - w.prepare(self.record, self.host, self.persist) - w.restore(self.record, self.host, self.persist) - self.host.result.update(phase='idle', active=[{'id': 'internal'}], error='identity mismatch') - self.assertFalse(w.restore(self.record, self.host, self.persist)) - def test_transport_rejects_wrong_pairing_and_shell_alias(self): - computer = {'ssh': {'alias': 'work-laptop'}, 'pairing_uuid': PAIR} - with patch.object(w, 'powershell', return_value={'ok': True, 'result': {'pairing_uuid': 'other', 'capture_id': DEVICE}}): - with self.assertRaisesRegex(ValueError, 'identity'): - w.remote(computer, self.host.display, 'probe') - with self.assertRaisesRegex(ValueError, 'alias'): - w.powershell('-oProxyCommand=bad', '') - def test_periodic_recovery_finishes_without_reconnecting(self): - # Exercise the controller's idle branch with a pending Windows journal. - ctl = object.__new__(controller.Controller) - ctl.now = lambda: 100 - ctl.persist = self.persist - ctl.host_factory = lambda *_: self.host - ctl.release_zone = lambda _: None - ctl.processes = type('Processes', (), {'pid': lambda *a: None, 'events': lambda *a: {}})() - w.prepare(self.record, self.host, self.persist) - w.restore(self.record, self.host, self.persist) - self.host.result.update(phase='idle', active=[{'id': 'internal'}]) - self.record.update(desired=False, phase='idle', observed='restore-pending', config={}, settings={}) - ctl.step(self.record, {}) - self.assertEqual(self.record['observed'], 'disconnected') - self.assertFalse(self.record['desired']) - self.assertEqual(self.record['journal'], {}) - def test_failed_stream_can_finish_offline_recovery_without_retrying(self): - ctl = object.__new__(controller.Controller) - ctl.now = lambda: 100 - ctl.persist = self.persist - ctl.host_factory = lambda *_: self.host - ctl.processes = type('Processes', (), {'pid': lambda *a: None, 'events': lambda *a: {}})() - w.prepare(self.record, self.host, self.persist) - w.restore(self.record, self.host, self.persist) - self.host.result.update(phase='idle', active=[{'id': 'internal'}]) - self.record.update(desired=True, phase='attention', observed='restore-pending', config={}, settings={}) - ctl.step(self.record, {}) - self.assertEqual(self.record['observed'], 'needs-attention') - self.assertEqual(self.record['phase'], 'attention') - self.assertEqual(self.record['journal'], {}) - -if __name__ == '__main__': - unittest.main() diff --git a/uninstall.sh b/uninstall.sh index e37dc5d..d06fec0 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -40,19 +40,12 @@ state="${XDG_STATE_HOME:-$HOME/.local/state}/hypertile" plugin_id="jmartin.hypertile" plugin_dst="$config/omarchy/plugins/$plugin_id" -# Keep recovery available until every managed source is disconnected/restored. -if [[ -e "$state/streams/state.json" ]]; then - python3 - "$state/streams/state.json" <<'PY' -import json +PYTHONPATH="$src/session" python3 - "$state" <<'PY_PREFLIGHT' +from pathlib import Path +from upgrade import check_legacy import sys -with open(sys.argv[1]) as source: - records = json.load(source).get("computers", {}) -pending = [name for name, r in records.items() if r.get("desired") or r.get("journal")] -if pending: - sys.exit("uninstall.sh: disconnect/restore remote sources first: " + ", ".join(pending) - + ". Use hypertile-ctl stream status --json for recovery actions.") -PY -fi +check_legacy(Path(sys.argv[1])) +PY_PREFLIGHT # Stop the writer before removing its code; retain recovery snapshots unless # --purge was requested. A missing/stopped service is harmless. @@ -67,6 +60,17 @@ if [[ -x "$bin/hypertile-scenes" ]]; then "$bin/hypertile-scenes" stop >/dev/null 2>&1 || true fi +# Keep pending host recovery tools intact and prevent legacy writer restarts. +mkdir -p "$state/streams" +exec 9>"$state/streams/writer.lock" +flock -sn 9 || { echo "uninstall.sh: legacy controller is still running" >&2; exit 1; } +PYTHONPATH="$src/session" python3 - "$state" <<'PY_CHECK' +from pathlib import Path +from upgrade import check_legacy +import sys +check_legacy(Path(sys.argv[1])) +PY_CHECK + # One backup per edited config file, overwritten on each edit. backup() { cp "$1" "$1.hypertile.bak" @@ -142,20 +146,18 @@ for f in hypertile.lua hypertile-json.lua hypertile-bridge.lua hypertile-layouts rm -f "$hypr/$f" 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" \ - "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/stream/audio.py" \ - "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/stream/quality.py" \ - "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/stream/browse.py" -rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/stream/windows_display.py" -for f in Guard.ps1 Policy.ps1 Display.cs Test.ps1 Install.ps1; do - rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/stream/windows/$f" +PYTHONPATH="$src/session" python3 - "$bin" "${XDG_DATA_HOME:-$HOME/.local/share}" <<'PY_CLEANUP' +from pathlib import Path +from upgrade import cleanup +import sys +cleanup(Path(sys.argv[1]), Path(sys.argv[2])) +PY_CLEANUP +rm -f "$bin/hypertile-session" "$bin/hypertile-scenes" +for module in service scene_recovery upgrade; do + rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/session/$module.py" +done +for module in scene_service apps ipc scenes browse; do + rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/scenes/$module.py" done echo "removed the engine files and hypertile-ctl"