diff --git a/docs/STREAMS-VALIDATION.md b/docs/STREAMS-VALIDATION.md index c17a4e6..fe23d56 100644 --- a/docs/STREAMS-VALIDATION.md +++ b/docs/STREAMS-VALIDATION.md @@ -226,3 +226,91 @@ 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 index 9beac6e..6a756e0 100644 --- a/docs/STREAMS.md +++ b/docs/STREAMS.md @@ -28,42 +28,77 @@ 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. -For automatic Mac preparation, BetterDisplay must already be running with CLI -integration enabled, and Sunshine must already have screen recording and input -permissions. Configure an approved SSH account with existing host-key trust and +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. -Find the physical display UUID on the Mac: +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 Mac adapter +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 it restarts Sunshine. Display groups and virtual displays are excluded. +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 a 2× absolute-pointer offset -with HiDPI capture. The example uses HiDPI off to keep ordinary absolute clicks -correct while retaining the same logical desktop size. For HiDPI, explicitly -choose `input: relative` and validate captured-pointer behavior; use -Ctrl+Alt+Shift+Z to release capture. `input: absolute` is the default. This is a -host/client compatibility limit, not a reason to silently select another display. +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 @@ -201,8 +236,25 @@ 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 and power every -30 seconds. Losing the SSH observation channel marks the source degraded while +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. diff --git a/docs/computers.example.json b/docs/computers.example.json index 6defcb9..3e31448 100644 --- a/docs/computers.example.json +++ b/docs/computers.example.json @@ -18,43 +18,12 @@ "hdr": false, "yuv444": false, "aspect": "fit", - "audio": "focus", + "audio": "host", "input": "absolute", - "keep_awake": "visible", + "keep_awake": "always", "display": { - "adapter": "betterdisplay", - "uuid": "00000000-0000-0000-0000-000000000002", - "mode": { - "resolution": "1920x1080", - "hidpi": false, - "refresh": 60 - }, - "require_ac": true + "adapter": "macos" } - }, - "desktop-capture": { - "stream_resolution": "2560x1440", - "fps": 60, - "bitrate": 60000, - "codec": "HEVC", - "decoder": "hardware", - "hdr": false, - "yuv444": false, - "aspect": "fit", - "audio": "focus", - "input": "absolute", - "keep_awake": "visible", - "display": { - "adapter": "betterdisplay", - "uuid": "00000000-0000-0000-0000-000000000002", - "mode": { - "resolution": "1920x1080", - "hidpi": false, - "refresh": 60 - }, - "require_ac": true - }, - "system_keys": "always" } }, "platform": "macos" @@ -65,17 +34,6 @@ "title": "WorkLaptop - Moonlight", "profiles": { "desktop": { - "stream_resolution": "2560x1600", - "fps": 60, - "bitrate": 60000, - "codec": "HEVC", - "audio": "focus", - "keep_awake": "visible", - "display": { - "adapter": "external" - } - }, - "meeting-headset": { "stream_resolution": "2560x1600", "fps": 60, "bitrate": 60000, @@ -85,17 +43,6 @@ "display": { "adapter": "external" } - }, - "meeting-audio": { - "stream_resolution": "2560x1600", - "fps": 60, - "bitrate": 60000, - "codec": "HEVC", - "audio": "continuous", - "keep_awake": "always", - "display": { - "adapter": "external" - } } }, "platform": "windows" diff --git a/plugin/Content.js b/plugin/Content.js index d653d4a..324bca2 100644 --- a/plugin/Content.js +++ b/plugin/Content.js @@ -27,6 +27,21 @@ 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" : diff --git a/plugin/ContentPane.qml b/plugin/ContentPane.qml index cb91269..9ff71bf 100644 --- a/plugin/ContentPane.qml +++ b/plugin/ContentPane.qml @@ -33,6 +33,7 @@ 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 || ({}) @@ -433,11 +434,20 @@ Column { 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.runtime.window; onClicked: pane.overlay.streamAction("focus", pane.source.computer, true) } - Action { text: "Disconnect"; tooltipText: "Close the view; the zone goes back to local windows"; onClicked: pane.overlay.streamAction("disconnect", pane.source.computer) } - Action { text: "Reconnect"; tooltipText: "Restart the view in the same zone"; enabled: !pane.overlay.busy && !!pane.runtime.window && pane.runtime.observed === "window-ready"; onClicked: pane.overlay.streamAction("reconnect", pane.source.computer) } - Action { text: "Retry"; visible: !pane.runtime.window && pane.runtime.desired === true; onClicked: pane.overlay.streamAction("retry", pane.source.computer) } - Action { text: "Restore display"; visible: !!pane.runtime.journal && pane.runtime.desired !== true; tooltipText: "Put the host's display settings back"; onClicked: pane.overlay.streamAction("restore", pane.source.computer) } + 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 { diff --git a/stream/audio.py b/stream/audio.py index e7aa5b5..b312b2b 100644 --- a/stream/audio.py +++ b/stream/audio.py @@ -9,7 +9,17 @@ def host_headset(pid, run=subprocess.run): 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) - streams = [v for v in json.loads(result.stdout) if str(v.get("properties", {}).get("application.process.id")) == str(pid)] + 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) diff --git a/stream/controller.py b/stream/controller.py index 1d2c30a..eed7272 100644 --- a/stream/controller.py +++ b/stream/controller.py @@ -24,7 +24,7 @@ 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 +from mac_display import same_setting, manages_mode from scenes import Manager from audio import host_headset from quality import Tracker, VideoStats @@ -92,7 +92,7 @@ def configuration(path): 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", "windows"), "unknown display adapter") + 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") @@ -101,10 +101,16 @@ def configuration(path): 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: @@ -141,7 +147,8 @@ def remote(self, operation, **values): if ssh.get("control_path"): argv += ["-S", ssh["control_path"]] argv += [ssh["user"] + "@" + self.computer["host"], "python3 -"] - request = {"operation": operation, "display_uuid": self.display["uuid"], + 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) @@ -186,13 +193,19 @@ def probe(self, pairing=True): if self.display["adapter"] == "windows": require(not observed.get("error"), observed.get("error", "Windows display helper error")) return {**info, "display": observed, "restoration": "managed"} - require(any(same_setting("mode", self.display["mode"], m) for m in observed["modes"]), + 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): - return self.remote("change", field=field, expected=expected, value=value) + 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): @@ -201,38 +214,77 @@ def prepare(record, host, persist): if host.display["adapter"] == "windows": return windows_display.prepare(record, host, persist) observed = host.probe(pairing=False) - desired = {"mode": host.display["mode"], "output": observed["identity"]["displayID"]} + 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 ("output", "mode"): + 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 ("output", "mode"): + 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 - require(same_setting(field, current, entry["original"]) and entry["phase"] == "intent", "restore-conflict: host setting changed while owned") - host.change(field, current, entry["applied"]) + 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): +def restore(record, host, persist, fields=("mode", "output")): if host.display["adapter"] == "windows": return windows_display.restore(record, host, persist) journal = record.get("journal", {}) @@ -240,25 +292,38 @@ def restore(record, host, persist): return True # Mode is a compound setting: changing only one component can select another # mode. Compare/restore the entire tuple, then the capture output. - observed = host.remote("probe") - for field in ("mode", "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 - if not same_setting(field, current, entry["applied"]): + 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 - host.change(field, current, entry["original"]) - observed = host.remote("probe") + 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 @@ -434,7 +499,7 @@ def public(self, 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") == "betterdisplay" + 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) @@ -576,6 +641,11 @@ def _command(self, request): 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) @@ -773,7 +843,7 @@ def step(self, r, snap): 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 + 30 + 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"): @@ -808,8 +878,8 @@ def step(self, r, snap): 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") == "betterdisplay" and now >= r.get("next_host_probe", 0): - r["next_host_probe"] = now + 30 + 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") @@ -821,8 +891,27 @@ def step(self, r, snap): 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" @@ -1036,6 +1125,8 @@ def main(): 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() diff --git a/stream/mac_display.py b/stream/mac_display.py index 84aab55..d70ab6b 100644 --- a/stream/mac_display.py +++ b/stream/mac_display.py @@ -12,6 +12,7 @@ import subprocess import tempfile import time +import uuid BETTER = "/Applications/BetterDisplay.app/Contents/MacOS/BetterDisplay" @@ -21,7 +22,14 @@ def same_setting(field, a, b): 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 abs(a["refresh"] - b["refresh"]) < .15) + 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): @@ -31,56 +39,142 @@ def run(argv): 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") - identity = request["display_uuid"] - 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]) - ids = json.loads(get("identifiers")) - if not isinstance(ids, dict) or ids.get("UUID", "").lower() != identity.lower(): - raise ValueError("display-missing: no unique UUID match") - graphics = ctypes.CDLL("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics") - 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") + 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") - mode = {"resolution": get("resolution"), "hidpi": get("hiDPI") == "on", - "refresh": float(get("refreshRate").removesuffix("Hz"))} + 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 get("displayModeList").splitlines(): + 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": "x".join(str(int(v) * (2 if mode["hidpi"] else 1)) for v in mode["resolution"].split("x"))} + "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 20 <= value["refresh"] <= 240: + 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") @@ -97,14 +191,7 @@ def get(key): finally: if os.path.exists(path): os.unlink(path) - 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; output restore may be pending") - run(["/usr/bin/open", "/Applications/Sunshine.app"]) + restart_sunshine() else: raise ValueError("unsupported display field") return {"changed": field} diff --git a/test/content.js b/test/content.js index 0f1e4a2..38a041c 100644 --- a/test/content.js +++ b/test/content.js @@ -76,3 +76,17 @@ 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) diff --git a/test/scenes.py b/test/scenes.py index b81aa99..52d1a88 100644 --- a/test/scenes.py +++ b/test/scenes.py @@ -392,6 +392,23 @@ def run(argv, **_): 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") diff --git a/test/stream.py b/test/stream.py index 6ed7aed..73eb182 100644 --- a/test/stream.py +++ b/test/stream.py @@ -9,7 +9,7 @@ import tempfile import subprocess import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "stream")) import controller as s @@ -19,6 +19,8 @@ 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"): @@ -38,12 +40,13 @@ def __init__(self, adapter="betterdisplay"): 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": "5"}, - "ac_power": self.ac_power, "modes": [MODE, OLD]} + 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) @@ -59,6 +62,65 @@ def change(self, field, expected, 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" @@ -431,6 +493,252 @@ def test_manual_changes_preserved_and_conflict_persisted(self): 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)) @@ -566,10 +874,52 @@ 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): - with self.assertRaisesRegex(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()