Mask relative-pointer negotiation by default outside macOS while preserving absolute pointer, keyboard, text, gamepad, and acknowledgements. Compile the Tao and browser Pointer Lock path only behind an explicit experimental feature, enforce release defaults, and document per-platform UAT.
- commit
- ba7108a6e6dca6b575c168ec75d74abacc50ba39
- parent
- bb4cb785c453c30ae9245131ae9d154a9d0c0a34
- author
- Dami <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+}