feat(portal): gate non-macOS pointer capture
- commit
- fe400ddb2d4153c31c3aa49df7ed2291602ff81b
- parent
- f53de5e47c6a2cadc2e3f951d89281f1ef61adef
- parent
- ba7108a6e6dca6b575c168ec75d74abacc50ba39
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | AGENTS.md | 8 | + |
| M | README.md | 6 | + |
| A | docs/ | 65 | ++++++++++ |
| M | docs/ | 7 | + |
| M | portal/ | 23 | ++++ |
| M | scripts/ | 6 | +- |
| M | scripts/ | 16 | ++- |
| M | scripts/ | 3 | + |
| M | scripts/ | 13 | +- |
| M | src-tauri/ | 3 | + |
| M | src-tauri/ | 20 | +++- |
| M | src-tauri/ | 1 | + |
| M | src-tauri/ | 191 | +++++++++++++++++++++++++----- |
| A | src-tauri/ | 37 | ++++++ |
@@ -75,6 +75,10 @@ Sigil host.
7575 coverage. Hardware acceptance that remains incomplete is listed below.7676 - Portal's window scales to the incoming stream while preserving aspect ratio;7777 larger client screens do not stretch the host image.78+- Portal's proven relative-pointer capture path is macOS-only. Non-macOS79+ builds must mask relative-pointer negotiation unless explicitly built with80+ `experimental-non-macos-pointer-capture`; that feature is for platform UAT81+ only and must not enter an ordinary or published build.7882 7983 ## Security boundary8084 @@ -200,6 +204,10 @@ Sigil host.
200204 `sigil-host.service` before calling the test complete.201205 202206 - On Linux with NVIDIA, set `WEBKIT_DISABLE_DMABUF_RENDERER=1` for Portal.207+- Treat `experimental-non-macos-pointer-capture` as an explicit non-release208+ build mode. Complete the per-platform checklist in209+ `docs/portal-platform-support.md`, including a real Portal -> UMPC session,210+ before changing a platform's support status or its default feature policy.203211 - Preserve `/Users/dami/Developer/sigil` untouched; it is the inherited source,204212 not this working repository.205213 - Preserve unrelated worktree changes. Never sweep generated credentials,@@ -81,6 +81,12 @@ Steam and games.
8181 while release builds reject it.8282 - Controller-first client navigation includes a D-pad PIN pad, negotiated8383 latest-state gamepad routing, and a one-second Back+Start escape chord.84+- Portal's proven relative-pointer capture is macOS-only. Default non-macOS85+ builds negotiate absolute pointer, keyboard/text, and gamepad without86+ advertising relative capture; the Tao + browser Pointer Lock path requires87+ the explicitly experimental `experimental-non-macos-pointer-capture` build88+ feature and the platform UAT in89+ [`docs/portal-platform-support.md`](docs/portal-platform-support.md).8490 - The Bazzite host has an allowlisted deterministic runtime package with8591 checksum-bound release IDs, serialized install/upgrade, tamper-checked8692 rollback, and package-owned user assets that follow the active release.@@ -0,0 +1,65 @@
1+# Portal platform and relative-pointer support2+3+Portal's first supported download is macOS arm64. Its relative-pointer path4+uses CoreGraphics cursor disassociation and has live Mac-to-Gamescope hardware5+evidence. Linux, Windows, and other Portal builds are not release targets yet.6+7+## Build policy8+9+| Client build | Relative pointer | Other negotiated input |10+| --- | --- | --- |11+| macOS | Enabled through the native CoreGraphics path | Absolute pointer, keyboard/text, and gamepad remain available when granted |12+| Non-macOS, default features | Not offered; any unexpected relative-pointer or pointer-feedback response is masked locally | Absolute pointer, keyboard/text, gamepad, and input acknowledgements remain available when granted |13+| Non-macOS with `experimental-non-macos-pointer-capture` | Enabled through Tao cursor grab plus browser Pointer Lock | Same independently granted input capabilities |14+15+The experimental flag makes the non-macOS code available for testing; it does16+not make that platform supported and must not appear in a published build.17+Compile both policies with:18+19+```bash20+cargo check --locked -p portal --all-targets21+cargo check --locked -p portal --all-targets \22+ --features experimental-non-macos-pointer-capture23+```24+25+The complete repository gate runs the default test suite and separately26+compiles the experimental branch. On Linux CI this proves both non-macOS27+configurations compile. Portal's macOS release job continues to build without28+feature flags.29+30+## Non-macOS acceptance checklist31+32+Complete this checklist independently for each OS/webview combination before33+proposing default or release support. Preserve the exact Portal commit,34+operating-system version, webview version, and window-system/session type with35+the evidence.36+37+1. Build and launch Portal without the experimental feature. Connect to a host38+ offering every input capability and confirm diagnostics do not report39+ relative pointer or pointer-position feedback. Confirm absolute motion and40+ buttons, keyboard/text, and a physical gamepad still work when their grants41+ are present.42+2. Build the exact same commit with43+ `experimental-non-macos-pointer-capture`. Confirm the embedded webview44+ implements `requestPointerLock`, reports ownership changes, and supplies45+ bounded `movementX`/`movementY` deltas.46+3. Against the UMPC Gamescope session, enter and exit control at least ten47+ times. Exercise motion, left/right click, both scroll axes, keyboard, and a48+ physical controller in an actual game. Confirm the remote pointer maps to49+ the native host surface rather than the encoded size.50+4. While control is active, test window focus loss/regain, Pointer Lock loss,51+ disconnect, reconnect, and application exit. Portal must visibly leave52+ control when ownership is lost, restore the local cursor, bound release53+ retries, and leave no held host input.54+5. Repeat under the platform's materially different window systems or55+ webviews. Linux requires separate Wayland/WebKitGTK and X11/WebKitGTK56+ evidence when both are claimed; Windows requires the shipped WebView257+ runtime. Record unavailable APIs as an explicit unsupported result rather58+ than bypassing the gate.59+6. Run `./scripts/verify-demo-build.sh` and retain the exact Portal-to-UMPC60+ session evidence required by `AGENTS.md`. A config check, unit test, or61+ compile-only result is not hardware acceptance.62+63+Removing the feature gate requires reviewed evidence for every platform being64+enabled and an explicit release-policy change. Do not infer support from Tao65+or webview API availability alone.@@ -9,6 +9,13 @@ and release gates.
99 Portal is always a compiled DMG download. There is no shell installer and no1010 fallback to an ad-hoc, demo, or development build.1111 12+The release build uses no optional Cargo features. In particular,13+`demo-direct-node` and `experimental-non-macos-pointer-capture` are forbidden14+from the default feature set and from the packaging command. The latter exists15+only to compile the unverified Tao + browser Pointer Lock path during16+non-macOS platform UAT; see17+[Portal platform support](portal-platform-support.md).18+1219 ## One-time GitHub and Apple setup1320 1421 Protect the GitHub environment named `main` with required reviewers and limit@@ -159,6 +159,29 @@ test('explicit native false bypasses browser lock and controller activation stay
159159 assert.equal(harness.runtime.activationGateActive, false);160160 });161161 162+test('absolute-pointer control never enters the gated relative-capture path', async () => {163+ let nativeGrabCalls = 0;164+ let pointerLockRequests = 0;165+ const harness = newHarness({166+ invokeCursorGrab: async () => {167+ nativeGrabCalls += 1;168+ return true;169+ },170+ pointerRequest: () => {171+ pointerLockRequests += 1;172+ },173+ });174+ harness.capabilities.relativePointer = false;175+ harness.capabilities.absolutePointer = true;176+177+ await harness.runtime.toggle();178+179+ assert.equal(harness.runtime.active, true);180+ assert.equal(nativeGrabCalls, 0);181+ assert.equal(pointerLockRequests, 0);182+ assert.equal(harness.calls.publish, 1);183+});184+162185 test('ordinary toggle exit queues neutral and releases held input without resetting controller escape', async () => {163186 const harness = newHarness({ invokeCursorGrab: async () => false });164187 await harness.runtime.toggle();@@ -17,8 +17,10 @@ Build and verify a distributable macOS DMG. This gate requires:
1717 - Tauri notarization credentials (App Store Connect API or Apple ID);1818 - strict code-signing, hardened runtime, Gatekeeper, and stapling checks.1919 20-The normal release is built without the demo-direct-node feature. Development21-or ad-hoc signatures are intentionally not accepted by this command.20+The normal release is built without optional Cargo features. Development21+features, including demo-direct-node and22+experimental-non-macos-pointer-capture, and ad-hoc signatures are23+intentionally not accepted by this command.2224 EOF2325 }2426 @@ -53,7 +53,8 @@ class PortalReleaseVerifierTests(unittest.TestCase):
5353 path.write_text(f'[package]\nname = "{name}"\nversion = "{version}"\n', encoding="utf-8")5454 (repo / "src-tauri" / "Cargo.toml").write_text(5555 f'[package]\nname = "portal"\nversion = "{version}"\n'56- '[features]\ndemo-direct-node = []\n',56+ '[features]\ndemo-direct-node = []\n'57+ 'experimental-non-macos-pointer-capture = []\n',5758 encoding="utf-8",5859 )5960 (repo / "src-tauri" / "tauri.conf.json").write_text(@@ -93,6 +94,19 @@ class PortalReleaseVerifierTests(unittest.TestCase):
9394 )9495 with self.assertRaisesRegex(VERIFIER.VerificationError, "enabled by default"):9596 VERIFIER.validate_source(repo, "v0.1.0", FakeGit())97+ (repo / "src-tauri" / "Cargo.toml").write_text(98+ '[package]\nname = "portal"\nversion = "0.1.0"\n'99+ '[features]\n'100+ 'default = ["experimental-non-macos-pointer-capture"]\n'101+ 'demo-direct-node = []\n'102+ 'experimental-non-macos-pointer-capture = []\n',103+ encoding="utf-8",104+ )105+ with self.assertRaisesRegex(106+ VERIFIER.VerificationError,107+ "experimental-non-macos-pointer-capture must not be enabled by default",108+ ):109+ VERIFIER.validate_source(repo, "v0.1.0", FakeGit())96110 97111 def write_assets(self, assets: pathlib.Path) -> None:98112 assets.mkdir()@@ -61,6 +61,9 @@ require_rust_test 'resolves_pinned_upstream_gamescope_pipewire_contract' \
6161 cargo fmt --all -- --check6262 cargo test --locked --workspace --all-targets6363 cargo clippy --locked --workspace --all-targets -- -D warnings64+cargo check --locked -p portal --all-targets \65+ --features experimental-non-macos-pointer-capture66+echo 'experimental_non_macos_pointer_capture_compile=ok'6467 if [[ "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" == 1 ]]; then6568 control_test='encoder_control_coalesces_latest_state_and_acknowledges_only_configured_idr'6669 gstreamer_test='in_process_gstreamer_x264_smoke'@@ -22,6 +22,10 @@ BUNDLE_IDENTIFIER = "sh.goq.portal"
2222 PLATFORM = "macos"2323 ARCHITECTURE = "arm64"2424 VERIFICATION = "developer-id+hardened-runtime+notarized+stapled+gatekeeper"25+NON_RELEASE_FEATURES = (26+ "demo-direct-node",27+ "experimental-non-macos-pointer-capture",28+)2529 TAG_PATTERN = re.compile(2630 r"^v(?P<version>(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\."2731 r"(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?)$"@@ -128,12 +132,11 @@ def validate_source(
128132 129133 portal_manifest = read_toml(repo_dir / "src-tauri" / "Cargo.toml")130134 features = portal_manifest.get("features", {})131- require("demo-direct-node" in features, "Portal demo feature declaration is missing")132135 default_features = features.get("default", [])133- require(134- isinstance(default_features, list) and "demo-direct-node" not in default_features,135- "demo-direct-node must not be enabled by default",136- )136+ require(isinstance(default_features, list), "Portal default features must be a list")137+ for feature in NON_RELEASE_FEATURES:138+ require(feature in features, f"Portal {feature} feature declaration is missing")139+ require(feature not in default_features, f"{feature} must not be enabled by default")137140 return {138141 "release_tag": release_tag,139142 "version": version,@@ -11,6 +11,9 @@ rust-version = "1.91"
1111 # Produces an optimized, explicitly insecure demo build while keeping ordinary1212 # release builds fail-closed around --dev-connect.1313 demo-direct-node = []14+# Compiles the unverified Tao + WebKit Pointer Lock relative-capture path on15+# non-macOS clients. This is a development/UAT switch, not a release feature.16+experimental-non-macos-pointer-capture = []1417 1518 [build-dependencies]1619 tauri-build = { version = "2", features = [] }@@ -44,13 +44,31 @@ const fn should_apply_native_cursor_association(current: bool, requested: bool)
4444 requested || current != requested4545 }4646 47-47+48+49+50+4851 fn set_native_cursor_grab(window: &tauri::WebviewWindow, grab: bool) -> Result<(), String> {4952 window5053 .set_cursor_grab(grab)5154 .map_err(|error| format!("Could not set native cursor grab to {grab}: {error}"))5255 }5356 57+58+59+60+61+fn set_native_cursor_grab(_window: &tauri::WebviewWindow, grab: bool) -> Result<(), String> {62+ if grab {63+ return Err(64+ "Relative pointer capture is unavailable on this Portal build; rebuild with the \65+ experimental-non-macos-pointer-capture feature for platform UAT"66+ .to_string(),67+ );68+ }69+ Ok(())70+}71+5472 5573 fn set_macos_cursor_hidden(hidden: bool) -> Result<(), String> {5674 let mut depth = MACOS_CURSOR_HIDE_DEPTH@@ -12,6 +12,7 @@ use tauri::ipc::Channel;
1212 use crate::commands::state::AppState;1313 use crate::media::network_diagnostics::{NetworkSessionDiagnostics, lock_network_diagnostics};1414 use crate::media::transport::{NegotiatedV1Stream, negotiate_v1};15+use crate::platform_capabilities::relative_pointer_capture_enabled;1516 1617 const INPUT_ALPN: &[u8] = sigil_protocol::LEGACY_INPUT_ALPN_V0;1718 pub(crate) const CLIENT_INPUT_QUEUE_CAPACITY: usize = 256;@@ -154,39 +155,48 @@ async fn open_negotiated_input_stream(
154155 Ok((connection, send, recv, negotiation))155156 }156157 157-fn input_capability_offers(grants: InvitationGrants) -> Vec<Vec<Capability>> {158- let base = [159- vec![160- Capability::RelativePointer,161- Capability::PointerPositionFeedback,162- Capability::PointerVisibilityFeedback,163- Capability::AbsolutePointer,164- Capability::Keyboard,165- Capability::Text,166- Capability::Gamepad,167- ],168- vec![169- Capability::RelativePointer,170- Capability::PointerPositionFeedback,171- Capability::AbsolutePointer,172- Capability::Keyboard,173- Capability::Text,174- Capability::Gamepad,175- ],176- vec![177- Capability::RelativePointer,178- Capability::AbsolutePointer,179- Capability::Keyboard,180- Capability::Text,181- Capability::Gamepad,182- ],183- vec![184- Capability::AbsolutePointer,185- Capability::Keyboard,186- Capability::Text,187- Capability::Gamepad,188- ],189- ];158+fn input_capability_offers_for(159+ grants: InvitationGrants,160+ relative_pointer_capture: bool,161+) -> Vec<Vec<Capability>> {162+ // Keep the relative compatibility ladder ordered from newest to oldest.163+ // When local relative capture is unavailable, deliberately collapse it to164+ // only the inherited absolute-pointer offer.165+ let mut base = Vec::with_capacity(if relative_pointer_capture { 4 } else { 1 });166+ if relative_pointer_capture {167+ base.extend([168+ vec![169+ Capability::RelativePointer,170+ Capability::PointerPositionFeedback,171+ Capability::PointerVisibilityFeedback,172+ Capability::AbsolutePointer,173+ Capability::Keyboard,174+ Capability::Text,175+ Capability::Gamepad,176+ ],177+ vec![178+ Capability::RelativePointer,179+ Capability::PointerPositionFeedback,180+ Capability::AbsolutePointer,181+ Capability::Keyboard,182+ Capability::Text,183+ Capability::Gamepad,184+ ],185+ vec![186+ Capability::RelativePointer,187+ Capability::AbsolutePointer,188+ Capability::Keyboard,189+ Capability::Text,190+ Capability::Gamepad,191+ ],192+ ]);193+ }194+ base.push(vec![195+ Capability::AbsolutePointer,196+ Capability::Keyboard,197+ Capability::Text,198+ Capability::Gamepad,199+ ]);190200 let has_input_grant = grants.contains(InvitationGrants::POINTER_KEYBOARD)191201 || grants.contains(InvitationGrants::GAMEPAD);192202 let mut offers = Vec::with_capacity(base.len() * 2);@@ -216,6 +226,27 @@ fn input_capability_offers(grants: InvitationGrants) -> Vec<Vec<Capability>> {
216226 offers217227 }218228 229+fn input_capability_offers(grants: InvitationGrants) -> Vec<Vec<Capability>> {230+ input_capability_offers_for(grants, relative_pointer_capture_enabled())231+}232+233+fn mask_unavailable_relative_pointer_capabilities(234+ mut capabilities: Vec<Capability>,235+ relative_pointer_capture: bool,236+) -> Vec<Capability> {237+ if !relative_pointer_capture {238+ capabilities.retain(|capability| {239+ !matches!(240+ capability,241+ Capability::RelativePointer242+ | Capability::PointerPositionFeedback243+ | Capability::PointerVisibilityFeedback244+ )245+ });246+ }247+ capabilities248+}249+219250 fn input_event_allowed(capabilities: &[Capability], event: &InputEvent) -> bool {220251 match event {221252 InputEvent::Probe => capabilities.contains(&Capability::InputAck),@@ -335,6 +366,12 @@ pub(crate) async fn open_input_session(
335366 ],336367 )337368 };369+ // A malformed or future host must not be able to re-enable a local input370+ // path that this Portal build deliberately withheld from its offers.371+ let capabilities = mask_unavailable_relative_pointer_capabilities(372+ capabilities,373+ relative_pointer_capture_enabled(),374+ );338375 let availability = InputAvailability::from_capabilities(&capabilities);339376 Ok(InputSession {340377 connection,@@ -794,7 +831,7 @@ mod tests {
794831 795832 796833 fn input_capability_fallbacks_remove_only_one_protocol_extension_at_a_time() {797- let offers = input_capability_offers(InvitationGrants::ALL);834+ let offers = input_capability_offers_for(InvitationGrants::ALL, true);798835 assert_eq!(offers.len(), 8);799836 let visibility = &offers[0];800837 let position = &offers[1];@@ -831,6 +868,92 @@ mod tests {
831868 );832869 }833870 871+ 872+ fn disabled_relative_capture_preserves_only_non_relative_input_offers() {873+ let offers = input_capability_offers_for(InvitationGrants::ALL, false);874+ assert_eq!(875+ offers,876+ [877+ vec![878+ Capability::AbsolutePointer,879+ Capability::Keyboard,880+ Capability::Text,881+ Capability::Gamepad,882+ Capability::InputAck,883+ ],884+ vec![885+ Capability::AbsolutePointer,886+ Capability::Keyboard,887+ Capability::Text,888+ Capability::Gamepad,889+ ],890+ ]891+ );892+ assert!(offers.iter().flatten().all(|capability| !matches!(893+ capability,894+ Capability::RelativePointer895+ | Capability::PointerPositionFeedback896+ | Capability::PointerVisibilityFeedback897+ )));898+ }899+900+ 901+ fn compiled_pointer_policy_controls_the_actual_negotiation_offers() {902+ let offers = input_capability_offers(InvitationGrants::ALL);903+ let offers_relative_pointer = offers904+ .iter()905+ .flatten()906+ .any(|capability| *capability == Capability::RelativePointer);907+ assert_eq!(offers_relative_pointer, relative_pointer_capture_enabled());908+ assert!(909+ offers910+ .iter()911+ .all(|offer| offer.contains(&Capability::AbsolutePointer))912+ );913+ }914+915+ 916+ fn negotiated_capability_mask_fails_closed_without_losing_other_input() {917+ let accepted = vec![918+ Capability::RelativePointer,919+ Capability::PointerPositionFeedback,920+ Capability::PointerVisibilityFeedback,921+ Capability::AbsolutePointer,922+ Capability::Keyboard,923+ Capability::Text,924+ Capability::Gamepad,925+ Capability::InputAck,926+ ];927+ let masked = mask_unavailable_relative_pointer_capabilities(accepted.clone(), false);928+ assert_eq!(929+ masked,930+ [931+ Capability::AbsolutePointer,932+ Capability::Keyboard,933+ Capability::Text,934+ Capability::Gamepad,935+ Capability::InputAck,936+ ]937+ );938+ assert_eq!(939+ mask_unavailable_relative_pointer_capabilities(accepted.clone(), true),940+ accepted941+ );942+ assert_eq!(943+ InputAvailability::from_capabilities(&masked),944+ InputAvailability {945+ relative_pointer: false,946+ pointer_position_feedback: false,947+ absolute_pointer: true,948+ keyboard: true,949+ text: true,950+ gamepad: true,951+ input_ack: true,952+ control: true,953+ }954+ );955+ }956+834957 835958 fn local_invitation_grants_bound_input_offers_before_the_host_intersection() {836959 let view_only = input_capability_offers(InvitationGrants::VIEW);@@ -0,0 +1,37 @@
1+/// macOS owns Portal's proven CoreGraphics relative-pointer capture path.2+/// Every other platform must opt into its still-experimental Tao + browser3+/// Pointer Lock contract at build time.4+pub(crate) const fn relative_pointer_capture_enabled_for(5+ target_is_macos: bool,6+ experimental_non_macos: bool,7+) -> bool {8+ target_is_macos || experimental_non_macos9+}10+11+pub(crate) const fn relative_pointer_capture_enabled() -> bool {12+ relative_pointer_capture_enabled_for(13+ cfg!(target_os = "macos"),14+ cfg!(feature = "experimental-non-macos-pointer-capture"),15+ )16+}17+18+19+mod tests {20+ use super::*;21+22+ 23+ fn non_macos_relative_pointer_capture_requires_the_experimental_feature() {24+ assert!(relative_pointer_capture_enabled_for(true, false));25+ assert!(relative_pointer_capture_enabled_for(true, true));26+ assert!(!relative_pointer_capture_enabled_for(false, false));27+ assert!(relative_pointer_capture_enabled_for(false, true));28+ }29+30+ 31+ fn compiled_policy_matches_target_and_feature_configuration() {32+ assert_eq!(33+ relative_pointer_capture_enabled(),34+ cfg!(target_os = "macos") || cfg!(feature = "experimental-non-macos-pointer-capture")35+ );36+ }37+}