cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

feat(portal): gate non-macOS relative pointer capture

Dami committed ba7108a
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>
14 files changed, +356 -43
MAGENTS.md8+
MREADME.md6+
Adocs/portal-platform-support.md65++++++++++
Mdocs/portal-release.md7+
Mportal/control-runtime.test.mjs23++++
Mscripts/package-macos-client.sh6+-
Mscripts/tests/test_portal_release.py16++-
Mscripts/verify-demo-build.sh3+
Mscripts/verify-portal-release.py13+-
Msrc-tauri/Cargo.toml3+
Msrc-tauri/src/commands/state.rs20+++-
Msrc-tauri/src/main.rs1+
Msrc-tauri/src/media/input_delivery.rs191+++++++++++++++++++++++++-----
Asrc-tauri/src/platform_capabilities.rs37++++++
M AGENTS.md +8 -0
M README.md +6 -0
A docs/portal-platform-support.md +65 -0
@@ -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.
M docs/portal-release.md +7 -0
M portal/control-runtime.test.mjs +23 -0
M scripts/package-macos-client.sh +4 -2
M scripts/tests/test_portal_release.py +15 -1
M scripts/verify-demo-build.sh +3 -0
M scripts/verify-portal-release.py +8 -5
M src-tauri/Cargo.toml +3 -0
M src-tauri/src/commands/state.rs +19 -1
M src-tauri/src/main.rs +1 -0
M src-tauri/src/media/input_delivery.rs +157 -34
@@ -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 #[test]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+ #[test]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+ #[test]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+ #[test]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 #[test]835958 fn local_invitation_grants_bound_input_offers_before_the_host_intersection() {836959 let view_only = input_capability_offers(InvitationGrants::VIEW);
A src-tauri/src/platform_capabilities.rs +37 -0